Files
shopdb-flask/plugins/geenforce/api/routes.py
cproudlock d157502d5b Add GE-Enforce P2 admin CRUD API: scopes, entries, reorder, simulate, publish
Full HTTP admin surface behind the manifest editor (geenforce.manage for edits,
geenforce.publish for shipping):

- Scopes: POST/GET/PUT/DELETE /scopes[/<id>] (create imaging PC types, edit the
  ComputerType/MeasuringToolType mapping + metadata, delete).
- Entries: POST /scopes/<id>/entries, PUT/DELETE /entries/<id>. Payloads use the
  manifest Applications[] shape; populate_entry (refactored out of build_entry)
  updates an entry in place, resetting omitted fields and replacing children.
- Reorder: PUT /scopes/<id>/entries/reorder enforces the ordering contract
  (body must list exactly the scope's entry ids).
- Simulate: GET /scopes/<id>/simulate?pctype&subtype&hostname&machinenumber&
  cmmversion returns which entries apply and which filter excluded the rest,
  reusing the engine-mirror filters. The "what would this PC get" tool.
- Publish lifecycle: POST /scopes/<id>/publish (records publishedby from JWT),
  GET /scopes/<id>/versions, GET .../versions/<n> (frozen manifest),
  POST /scopes/<id>/rollback.

Entry type validated against ENTRY_TYPES; 8 CRUD tests. JWT+permission gated so
the authz sweep covers them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:13:27 -04:00

531 lines
21 KiB
Python

"""GE-Enforce plugin API.
Two audiences:
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
- Client (service token, geenforce.fetch scope): GET /manifest serves the
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
collector's managed-token pattern (X-API-Key or Bearer PAT), plus an optional
GEENFORCE_API_KEY env bootstrap.
"""
from functools import wraps
from flask import Blueprint, request, current_app, Response
from flask_jwt_extended import jwt_required
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized,
)
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
from ..filters import (
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
matches_cmmversion,
)
from .. import service
geenforce_bp = Blueprint('geenforce', __name__)
FETCH_SCOPE = 'geenforce.fetch'
REPORT_SCOPE = 'geenforce.report'
def _require_service_token(scope):
"""Decorator factory: require `scope` service token OR the env bootstrap key."""
def wrapper(f):
@wraps(f)
def decorated(*args, **kwargs):
if service_token_authorized(scope):
return f(*args, **kwargs)
expected = current_app.config.get('GEENFORCE_API_KEY')
if expected and request.headers.get('X-API-Key') == expected:
return f(*args, **kwargs)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
return decorated
return wrapper
require_fetch_token = _require_service_token(FETCH_SCOPE)
require_report_token = _require_service_token(REPORT_SCOPE)
# -- client-facing endpoint ---------------------------------------------------
@geenforce_bp.route('/manifest', methods=['GET'])
@require_fetch_token
def get_manifest():
"""Serve the current published manifest for a scope (fat-client: full scope).
Query: pctype (=scopename, required), phase (default runtime). The engine
filters client-side, matching today, so subtype/hostname/machinenumber/
cmmversion are accepted but not applied here.
"""
scopename = (request.args.get('pctype') or '').strip()
phase = (request.args.get('phase') or 'runtime').strip()
if not scopename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'pctype is required', http_code=400)
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
return error_response(ErrorCodes.NOT_FOUND,
f'No scope: {scopename}', http_code=404)
published = scope.publishedversions.filter_by(iscurrent=True).first()
if not published:
return error_response(ErrorCodes.NOT_FOUND,
f'{scopename} has no published version',
http_code=404)
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
if request.headers.get('If-None-Match') == etag:
return Response(status=304, headers={'ETag': etag})
return Response(published.manifestjson, mimetype='application/json',
headers={'ETag': etag,
'X-Manifest-Version': str(published.versionnumber)})
# -- client reporting (observed state) ----------------------------------------
@geenforce_bp.route('/report', methods=['POST'])
@require_report_token
def post_report():
"""Record one PC's enforcement cycle: applied version (did it receive the
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
payload = request.get_json(silent=True) or {}
if not (payload.get('hostname') or '').strip():
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required', http_code=400)
try:
report = service.record_enforcement_report(payload)
db.session.commit()
except ValueError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
http_code=400)
return success_response({'reportid': report.reportid,
'status': report.status}, message='recorded')
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
@geenforce_bp.route('/scopes', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_scopes():
"""List imaging PC-type scopes with entry + published-version counts."""
scopes = ManifestScope.query.order_by(
ManifestScope.phase, ManifestScope.scopename).all()
data = []
for scope in scopes:
current = scope.publishedversions.filter_by(iscurrent=True).first()
data.append({
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
'entrycount': len(scope.entries),
'publishedversion': current.versionnumber if current else None,
})
return success_response(data)
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def preview_scope(scopeid):
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
http_code=404)
return success_response({'scopename': scope.scopename,
'manifest': scope_to_manifest(scope)})
# -- scope CRUD (geenforce.manage) --------------------------------------------
def _scope_summary(scope):
current = scope.publishedversions.filter_by(iscurrent=True).first()
return {
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'description': scope.description,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
'entrycount': len(scope.entries),
'publishedversion': current.versionnumber if current else None,
}
def _entry_payload(entry):
"""Manifest-entry dict with entryid + sortorder for the editor."""
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder}
data.update(entry_to_dict(entry))
return data
@geenforce_bp.route('/scopes', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_scope():
payload = request.get_json(silent=True) or {}
scopename = (payload.get('scopename') or '').strip()
phase = (payload.get('phase') or 'runtime').strip()
if not scopename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'scopename is required', http_code=400)
if phase not in PHASES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'phase must be one of {PHASES}', http_code=400)
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
return error_response(ErrorCodes.VALIDATION_ERROR,
'scope already exists', http_code=400)
scope = ManifestScope(
scopename=scopename, phase=phase,
manifestversion=str(payload.get('manifestversion', '1.0')),
description=payload.get('description'),
computertypeid=payload.get('computertypeid'),
measuringtooltypeid=payload.get('measuringtooltypeid'),
iscommon=bool(payload.get('iscommon', scopename == 'common')))
db.session.add(scope)
db.session.commit()
return success_response(_scope_summary(scope), http_code=201)
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
data = _scope_summary(scope)
data['entries'] = [_entry_payload(e) for e in scope.entries]
return success_response(data)
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
payload = request.get_json(silent=True) or {}
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
if field in payload:
setattr(scope, field, payload[field])
if 'manifestversion' in payload:
scope.manifestversion = str(payload['manifestversion'])
if 'iscommon' in payload:
scope.iscommon = bool(payload['iscommon'])
db.session.commit()
return success_response(_scope_summary(scope))
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
db.session.delete(scope)
db.session.commit()
return success_response({'deleted': scopeid})
# -- entry CRUD (geenforce.manage) --------------------------------------------
def _validate_entry(payload):
"""Return an error string, or None if the entry payload is valid."""
if not (payload.get('Name') or '').strip():
return 'entry Name is required'
if payload.get('Type') not in ENTRY_TYPES:
return f'entry Type must be one of {ENTRY_TYPES}'
return None
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_entry(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
payload = request.get_json(silent=True) or {}
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
entry = build_entry(payload, nextorder)
scope.entries.append(entry)
db.session.commit()
return success_response(_entry_payload(entry), http_code=201)
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_entry(entryid):
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
payload = request.get_json(silent=True) or {}
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
populate_entry(entry, payload)
db.session.commit()
return success_response(_entry_payload(entry))
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_entry(entryid):
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
db.session.delete(entry)
db.session.commit()
return success_response({'deleted': entryid})
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def reorder_entries(scopeid):
"""Set entry order from a list of entryids (the ordering contract)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
order = (request.get_json(silent=True) or {}).get('order') or []
owned = {e.entryid: e for e in scope.entries}
if set(order) != set(owned):
return error_response(ErrorCodes.VALIDATION_ERROR,
'order must list exactly this scope\'s entry ids',
http_code=400)
for position, entryid in enumerate(order):
owned[entryid].sortorder = position
db.session.commit()
return success_response({'order': order})
# -- simulate: "what would this PC get" ---------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def simulate_scope(scopeid):
"""Which draft entries apply to a given machine profile, and why the rest
are filtered out (reuses the engine-mirror filters)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
profile = {
'pctype': request.args.get('pctype') or scope.scopename,
'subtype': request.args.get('subtype'),
'hostname': request.args.get('hostname'),
'machinenumber': request.args.get('machinenumber'),
'cmmversion': request.args.get('cmmversion'),
}
applied, filtered = [], []
for entry in scope.entries:
entry_dict = entry_to_dict(entry)
if entry_applies(entry_dict, profile):
applied.append(entry.name)
else:
reasons = []
if not matches_pctype(entry_dict, profile['pctype'], profile['subtype']):
reasons.append('PCTypes')
if not matches_hostname(entry_dict, profile['hostname']):
reasons.append('TargetHostnames')
if not matches_machinenumber(entry_dict, profile['machinenumber']):
reasons.append('TargetMachineNumbers')
if not matches_cmmversion(entry_dict, profile['cmmversion']):
reasons.append('_CmmVersion')
filtered.append({'name': entry.name, 'filteredby': reasons})
return success_response({'profile': profile, 'applied': applied,
'filtered': filtered})
# -- publish lifecycle (geenforce.publish) ------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def publish_scope_route(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
from flask_jwt_extended import get_jwt_identity
notes = (request.get_json(silent=True) or {}).get('notes')
try:
publishedby = int(get_jwt_identity())
except (TypeError, ValueError):
publishedby = None
version = service.publish_scope(scope.scopename, scope.phase,
notes=notes, publishedby=publishedby)
db.session.commit()
return success_response({'versionnumber': version}, http_code=201)
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_versions(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
versions = ManifestPublishedVersion.query.filter_by(
scopeid=scopeid).order_by(
ManifestPublishedVersion.versionnumber.desc()).all()
return success_response([{
'versionnumber': v.versionnumber,
'iscurrent': v.iscurrent,
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
'publishedby': v.publishedby,
'notes': v.notes,
} for v in versions])
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_version(scopeid, versionnumber):
version = ManifestPublishedVersion.query.filter_by(
scopeid=scopeid, versionnumber=versionnumber).first()
if not version:
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
http_code=404)
import json as _json
return success_response({'versionnumber': version.versionnumber,
'manifest': _json.loads(version.manifestjson)})
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def rollback_scope_route(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
if versionnumber is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'versionnumber is required', http_code=400)
try:
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
db.session.commit()
except ValueError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
return success_response({'versionnumber': int(versionnumber)})
def _current_published_version(scopename, phase):
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
return None
published = scope.publishedversions.filter_by(iscurrent=True).first()
return published.versionnumber if published else None
@geenforce_bp.route('/reports', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_reports():
"""Latest enforcement report per PC (fleet compliance view).
Each row shows the applied vs latest published version (receivedlatest = the
PC picked up the update) and the install/skip/fail counts + status.
Optional filters: hostname, scopename.
"""
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
hostname = request.args.get('hostname')
scopename = request.args.get('scopename')
if hostname:
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
if scopename:
query = query.filter_by(scopename=scopename)
reports = query.order_by(
ManifestEnforcementReport.receivedat.desc()).all()
latest_cache = {}
data = []
for report in reports:
key = (report.scopename, report.phase)
if key not in latest_cache:
latest_cache[key] = _current_published_version(*key)
latest = latest_cache[key]
data.append({
'reportid': report.reportid,
'hostname': report.hostname,
'scopename': report.scopename,
'phase': report.phase,
'appliedversion': report.appliedversion,
'latestversion': latest,
'receivedlatest': (latest is not None
and report.appliedversion == latest),
'enforcerversion': report.enforcerversion,
'status': report.status,
'installed': report.installedcount,
'skipped': report.skippedcount,
'failed': report.failedcount,
'filtered': report.filteredcount,
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
if report.lastcheckin else None),
'receivedat': (report.receivedat.isoformat() + 'Z'
if report.receivedat else None),
})
return success_response(data)
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_report(reportid):
"""One report with per-entry outcomes (self-heal / failure detail)."""
report = db.session.get(ManifestEnforcementReport, reportid)
if not report:
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
http_code=404)
latest = _current_published_version(report.scopename, report.phase)
return success_response({
'reportid': report.reportid,
'hostname': report.hostname,
'scopename': report.scopename,
'phase': report.phase,
'appliedversion': report.appliedversion,
'latestversion': latest,
'receivedlatest': (latest is not None
and report.appliedversion == latest),
'status': report.status,
'results': [{
'entryname': r.entryname,
'action': r.action,
'selfhealed': r.selfhealed,
'exitcode': r.exitcode,
'message': r.message,
} for r in report.results],
})