"""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). """ import ipaddress import os import time from functools import wraps from flask import Blueprint, request, Response, send_file, current_app, g from flask_jwt_extended import jwt_required from sqlalchemy.exc import IntegrityError from shopdb.api import ( db, cache, success_response, error_response, ErrorCodes, require_permission, authorized_service_token, Setting, Application, ) SHAREROOT_SETTING = 'geenforce_share_root' from ..models import ( ManifestScope, ManifestEntry, ManifestPublishedVersion, ManifestEnforcementReport, ManifestPayload, ManifestBlob, 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' # Comma-separated CIDRs / plain IPs whose callers may reach the client # endpoints WITHOUT a per-PC token (network-trust for a vaulted fleet). # Empty/unset = disabled (token stays the only path). ALLOWED_CIDRS_SETTING = 'geenforce_allowed_cidrs' def _trusted_client_ip(): """The trustworthy caller IP for the AUTH allowlist. Uses request.remote_addr, NOT the raw X-Forwarded-For header. Proxies APPEND to X-Forwarded-For, so its first hop is attacker-controlled: parsing it (as _client_ip does for rate-limiting) would let any caller send 'X-Forwarded-For: ' and bypass the token. DEPLOYMENT DEPENDENCY, not a property of this function. remote_addr is only trustworthy because of what sits in front: - Behind IIS, the URL-Rewrite rule OVERWRITES X-Forwarded-For with the real TCP peer, and waitress (--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for) derives remote_addr from it. - Remove that rule and IIS still forwards whatever X-Forwarded-For the CALLER sent. waitress trusts it because it comes from 127.0.0.1, so remote_addr becomes attacker-controlled and this allowlist is bypassable from anywhere on the network. It does NOT fail closed. - A client reaching waitress directly is not a trusted proxy, so there remote_addr is its own peer address. The rule is the control. See docs/geenforce-api-cutover.md; the Windows installer enables it and verifies it is live at the end of an install. """ return request.remote_addr or '' def _ip_allowlisted(): """True when the caller IP falls in the configured geenforce allowlist. Lets vaulted fleet PCs reach the client endpoints without a per-PC token - network trust replaces the shared secret. Fails closed: an unparseable caller IP or malformed allowlist entry never matches. Empty setting = off. Uses the SPOOF-RESISTANT remote_addr (see _trusted_client_ip), never the raw X-Forwarded-For header. """ raw = (Setting.get(ALLOWED_CIDRS_SETTING) or '').strip() if not raw: return False try: ip = ipaddress.ip_address(_trusted_client_ip()) except ValueError: return False for part in raw.split(','): part = part.strip() if not part: continue try: if ip in ipaddress.ip_network(part, strict=False): return True except ValueError: continue return False def _require_service_token(scope): """Decorator factory: require a managed service token scoped for `scope`, OR a caller from the configured IP allowlist. Two client-auth paths: 1. a managed geenforce.fetch/report token (X-API-Key or Bearer PAT), or 2. a source IP in geenforce_allowed_cidrs (vault network trust). No env-key fallback. Fail-closed: neither path -> 401. """ def wrapper(f): @wraps(f) def decorated(*args, **kwargs): token = authorized_service_token(scope) if token is not None: # Stash for the route so it can honor the token's resource # binding (token.resourcescopelist restricts which manifest # scopes + blobs this token may pull; None = unrestricted). g.geenforce_token = token return f(*args, **kwargs) # Network-trust path: an allowlisted vault IP reaches the client # endpoints with no token. No token = no resource-scope binding # (unrestricted), which the perimeter-trust model accepts. if _ip_allowlisted(): g.geenforce_token = None return f(*args, **kwargs) return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', http_code=401) return decorated return wrapper def _token_resource_scopes(): """Resource-scope allowlist for the authorized token, or None if the token may reach any scope. A bound token (e.g. a display's) is pinned to its own manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs.""" token = getattr(g, 'geenforce_token', None) return token.resourcescopelist if token is not None else None 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) allowed = _token_resource_scopes() if allowed is not None and scopename not in allowed: return error_response(ErrorCodes.FORBIDDEN, 'token is not allowed this scope', http_code=403) 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 payload download (share-less installer delivery) ------------------ # GET /payload hardening. This endpoint is reachable with only a read-only # geenforce.fetch token, so a leaked display token must not be able to pull # unbounded bytes or hammer it. Two bounds cap the blast radius: # - a per-IP fixed-window rate limit (same shape and cache extension as the # login limiter in shopdb.core.api.auth, so no new dependency), and # - a served-size ceiling: refuse to stream a blob larger than the cap. # Both are overridable via app config for a site that ships bigger installers. PAYLOAD_DOWNLOAD_MAX_BYTES = 512 * 1024 * 1024 PAYLOAD_DOWNLOAD_RATELIMIT_MAX = 120 PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS = 60 def _client_ip(): """Caller IP for rate limiting, honoring the first X-Forwarded-For hop (mirrors shopdb.core.api.auth._login_ip).""" forwarded = request.headers.get('X-Forwarded-For') if forwarded: return forwarded.split(',')[0].strip() return request.remote_addr or 'unknown' def _payload_max_bytes(): return current_app.config.get('GEENFORCE_PAYLOAD_MAX_BYTES', PAYLOAD_DOWNLOAD_MAX_BYTES) def _payload_download_ratelimited(): """Fixed-window per-IP limiter for the payload download endpoint. Backed by the existing cache extension (no new dependency), same shape as the login limiter. Under the default SimpleCache the counter is per-process, so with N gunicorn workers the effective budget is N x the configured max; a shared cache backend (Redis/memcached) tightens it to a true global budget. Returns True when the caller is over budget for the current window. """ if not current_app.config.get('GEENFORCE_PAYLOAD_RATELIMIT_ENABLED', True): return False window = current_app.config.get( 'GEENFORCE_PAYLOAD_RATELIMIT_WINDOW_SECONDS', PAYLOAD_DOWNLOAD_RATELIMIT_WINDOW_SECONDS) maxhits = current_app.config.get( 'GEENFORCE_PAYLOAD_RATELIMIT_MAX', PAYLOAD_DOWNLOAD_RATELIMIT_MAX) # Time bucket makes this a fixed window: the key rolls over at each window # boundary, so a per-hit set() cannot turn it into a sliding window. bucket = int(time.time() // window) if window > 0 else 0 key = f'geenforcepayloadratelimit:{_client_ip()}:{bucket}' count = cache.get(key) or 0 if count >= maxhits: return True cache.set(key, count + 1, timeout=window) return False @geenforce_bp.route('/payload/', methods=['GET']) @require_fetch_token def get_payload(sha256): """Serve a payload blob by content hash over HTTPS. Lets share-less (Intune/local-account) PCs pull installers the manifest references without SMB. Sources: the content-addressed blob store (large http payloads) first, then an inline DB payload with this hash. The client re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the hash (content is immutable). Hardened: per-IP rate limited, and a blob over the served-size ceiling is refused (413) rather than streamed. """ if _payload_download_ratelimited(): return error_response('RATE_LIMITED', 'Too many payload downloads. Try again later.', http_code=429) sha = (sha256 or '').strip().lower() if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha): return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256', http_code=400) # A resource-bound token may only pull a blob its own scope(s) reference. # Return 404 (not 403) so it cannot probe which hashes exist. Checked before # the 304 shortcut so a bound token cannot even confirm a hash via ETag. allowed = _token_resource_scopes() if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed): return error_response(ErrorCodes.NOT_FOUND, 'no such payload', http_code=404) etag = f'"{sha}"' if request.headers.get('If-None-Match') == etag: return Response(status=304, headers={'ETag': etag}) maxbytes = _payload_max_bytes() blob = db.session.get(ManifestBlob, sha) if blob and os.path.isfile(service.blob_path(sha)): if blob.sizebytes is not None and blob.sizebytes > maxbytes: return error_response('PAYLOAD_TOO_LARGE', 'payload exceeds the download size limit', http_code=413) response = send_file( service.blob_path(sha), mimetype=blob.contenttype or 'application/octet-stream', as_attachment=True, download_name=blob.filename) response.headers['ETag'] = etag return response inline = ManifestPayload.query.filter_by(payloadsha256=sha).first() if inline: if inline.payloadbytes is not None and len(inline.payloadbytes) > maxbytes: return error_response('PAYLOAD_TOO_LARGE', 'payload exceeds the download size limit', http_code=413) return Response( inline.payloadbytes, mimetype=inline.contenttype or 'application/octet-stream', headers={'ETag': etag, 'Content-Disposition': f'attachment; filename="{inline.filename}"'}) return error_response(ErrorCodes.NOT_FOUND, 'No payload for that hash', http_code=404) # -- 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)}) @geenforce_bp.route('/applications', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def list_applications(): """The core Applications catalog (id + name) for the curated entry->app link picker in the entry editor.""" apps = Application.query.filter_by(isactive=True).order_by( Application.appname).all() return success_response([{'appid': a.appid, 'appname': a.appname} for a in apps]) # -- 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 + the curated app link. appid/appname are shopdb metadata (not manifest fields), added alongside the rendered manifest entry for the editor. """ data = {'entryid': entry.entryid, 'sortorder': entry.sortorder, 'appid': entry.appid, 'appname': None} if entry.appid: app = db.session.get(Application, entry.appid) data['appname'] = app.appname if app else None data.update(entry_to_dict(entry)) # Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor. data['payloadsource'] = entry.payloadsource data['payloadref'] = entry.payloadref data['payloadsha256'] = entry.payloadsha256 data['haspayload'] = (entry.payloadsource == 'inline' and entry.payloadsha256 is not None) return data def _apply_app_link(entry, payload): """Set the optional curated appid from the payload (shopdb metadata, not a manifest field, so handled outside populate_entry). Ignores an unknown id.""" if 'appid' not in payload: return appid = payload.get('appid') if appid in (None, '', 0): entry.appid = None return # Ignore a non-numeric id rather than 500 (matches the docstring contract). try: appid = int(appid) except (ValueError, TypeError): return if db.session.get(Application, appid): entry.appid = appid @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) _apply_app_link(entry, payload) 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) _apply_app_link(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}) # -- compliance: "how much of the fleet has this app" ------------------------- @geenforce_bp.route('/scopes//compliance', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def scope_compliance(scopeid): """Fleet-install coverage per app-linked entry (from collected PC data). One row per entry that carries a curated appid; installed/version-match counts come from the computers plugin's ComputerInstalledApp. Degrades to null counts (computersplugin: false) when that plugin is absent. """ scope = db.session.get(ManifestScope, scopeid) if not scope: return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) return success_response(service.compliance_for_scope(scope)) # -- inline payload upload / fetch (small scripts + configs) ------------------ PAYLOAD_MAX_BYTES = 1024 * 1024 @geenforce_bp.route('/entries//payload', methods=['POST']) @jwt_required() @require_permission('geenforce.publish') def upload_entry_payload(entryid): """Store an inline payload (<= 1 MB) for an entry and point the entry at it.""" entry = db.session.get(ManifestEntry, entryid) if not entry: return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) uploaded = request.files.get('file') if uploaded is None: return error_response(ErrorCodes.VALIDATION_ERROR, 'a file is required', http_code=400) rawbytes = uploaded.read() if not rawbytes: return error_response(ErrorCodes.VALIDATION_ERROR, 'payload is empty', http_code=400) if len(rawbytes) > PAYLOAD_MAX_BYTES: return error_response(ErrorCodes.VALIDATION_ERROR, 'payload exceeds 1 MB limit', http_code=400) service.store_inline_payload(entry, uploaded.filename, uploaded.mimetype, rawbytes) db.session.commit() return success_response(_entry_payload(entry), http_code=201) @geenforce_bp.route('/entries//payload', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def download_entry_payload(entryid): """Return the stored inline payload bytes for an entry (404 if none).""" entry = db.session.get(ManifestEntry, entryid) if not entry: return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404) payload = ManifestPayload.query.filter_by(entryid=entryid).first() if not payload: return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload', http_code=404) return Response( payload.payloadbytes, mimetype=payload.contenttype or 'application/octet-stream', headers={'Content-Disposition': f'attachment; filename="{payload.filename}"'}) # -- 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)}) def _normalize_cidrs(raw): """Validate + normalize a comma/newline-separated CIDR list. Returns (normalized_csv, bad_entries). Bare IPs are accepted (host route).""" good, bad = [], [] for part in (raw or '').replace('\n', ',').split(','): part = part.strip() if not part: continue try: good.append(str(ipaddress.ip_network(part, strict=False))) except ValueError: bad.append(part) return ','.join(good), bad @geenforce_bp.route('/config', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def get_config(): """Plugin config: the on-share export root + the client IP allowlist.""" shareroot = Setting.query.filter_by(key=SHAREROOT_SETTING).first() cidrs = Setting.query.filter_by(key=ALLOWED_CIDRS_SETTING).first() return success_response({ 'shareroot': shareroot.value if shareroot else '', 'allowedcidrs': cidrs.value if cidrs else '', }) @geenforce_bp.route('/config', methods=['PUT']) @jwt_required() @require_permission('geenforce.publish') def put_config(): payload = request.get_json(silent=True) or {} result = {} if 'shareroot' in payload: shareroot = (payload.get('shareroot') or '').strip() Setting.set(SHAREROOT_SETTING, shareroot, valuetype='string', category='geenforce', description='On-share export root for GE-Enforce manifests') result['shareroot'] = shareroot if 'allowedcidrs' in payload: normalized, bad = _normalize_cidrs(payload.get('allowedcidrs')) if bad: return error_response( ErrorCodes.VALIDATION_ERROR, 'Invalid CIDR(s): ' + ', '.join(bad), http_code=400) Setting.set(ALLOWED_CIDRS_SETTING, normalized, valuetype='string', category='geenforce', description='Client IP allowlist (CIDRs) that may reach the ' 'GE-Enforce client endpoints without a token') result['allowedcidrs'] = normalized db.session.commit() return success_response(result) @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], })