"""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, Response from flask_jwt_extended import jwt_required from sqlalchemy.exc import IntegrityError from shopdb.api import ( db, success_response, error_response, ErrorCodes, require_permission, service_token_authorized, Setting, ) SHAREROOT_SETTING = 'geenforce_share_root' 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 a managed service token scoped for `scope`. Tokens are the only client auth path (the client kit provisions a geenforce.fetch/report token). No env-key fallback: it was never wired into config and an unscoped shared key is a needless backdoor. """ def wrapper(f): @wraps(f) def decorated(*args, **kwargs): if service_token_authorized(scope): 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//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/', 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/', 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/', 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//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) try: db.session.commit() except IntegrityError: db.session.rollback() return error_response(ErrorCodes.VALIDATION_ERROR, 'an entry with that Name already exists in this scope', http_code=400) return success_response(_entry_payload(entry), http_code=201) @geenforce_bp.route('/entries/', 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) # Free the one-to-one InUseCheck (unique entryid) before populate re-inserts # it, so the replacement does not collide with the old row mid-flush. if entry.inusecheck is not None: db.session.delete(entry.inusecheck) entry.inusecheck = None db.session.flush() populate_entry(entry, payload) try: db.session.commit() except IntegrityError: db.session.rollback() return error_response(ErrorCodes.VALIDATION_ERROR, 'an entry with that Name already exists in this scope', http_code=400) return success_response(_entry_payload(entry)) @geenforce_bp.route('/entries/', 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//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//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'), 'phase': scope.phase, } 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 = [] strict_allowed = profile.get('phase') == 'preinstall' if not matches_pctype(entry_dict, profile['pctype'], profile['subtype'], strict_allowed): 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//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//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//versions/', 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('/config', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def get_config(): """Plugin config: the on-share export root (for export-to-share).""" setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() return success_response({'shareroot': setting.value if setting else ''}) @geenforce_bp.route('/config', methods=['PUT']) @jwt_required() @require_permission('geenforce.publish') def put_config(): payload = request.get_json(silent=True) or {} Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(), valuetype='string', category='geenforce', description='On-share export root for GE-Enforce manifests') db.session.commit() return success_response({'shareroot': (payload.get('shareroot') or '').strip()}) @geenforce_bp.route('/scopes//export-share', methods=['POST']) @jwt_required() @require_permission('geenforce.publish') def export_share_route(scopeid): """Write the scope's current published JSON to the configured share root (backing up the old file to _meta/history first).""" scope = db.session.get(ManifestScope, scopeid) if not scope: return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first() shareroot = setting.value if setting else '' if not shareroot: return error_response(ErrorCodes.VALIDATION_ERROR, 'Configure the share root first', http_code=400) try: path = service.export_scope_to_share(scope.scopename, scope.phase, shareroot) except (ValueError, OSError) as exc: return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400) return success_response({'path': path}) @geenforce_bp.route('/scopes//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/', 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], })