"""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 datetime import datetime, timedelta 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' # Sort floor for revisions with no lastseenat (they sort last regardless). _EPOCH = datetime.min from ..models import ( ManifestScope, ManifestEntry, ManifestPublishedVersion, ManifestEnforcementReport, ManifestEnforcementResult, 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 body = request.get_json(silent=True) or {} notes = body.get('notes') try: publishedby = int(get_jwt_identity()) except (TypeError, ValueError): publishedby = None try: version = service.publish_scope( scope.scopename, scope.phase, notes=notes, publishedby=publishedby, force=bool(body.get('force'))) except service.LibVersionTooOldError as error: # 409, not 500: the request is well-formed, the fleet is not ready. The # hosts come back so the caller can act without going hunting. return error_response( ErrorCodes.CONFLICT, str(error), http_code=409, details={'required': error.required, 'floor': error.floor, 'hosts': error.hosts}) db.session.commit() return success_response({'versionnumber': version}, http_code=201) @geenforce_bp.route('/scopes//publish-preflight', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def publish_preflight(scopeid): """Would publishing this scope outrun the fleet's enforcer lib? Lets the UI warn BEFORE someone clicks publish, rather than only refusing afterwards. """ scope = db.session.get(ManifestScope, scopeid) if not scope: return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404) hosts, floor = service.hosts_below_libversion( scope.scopename, scope.phase, scope.manifestversion) return success_response({ 'required': scope.manifestversion, 'floor': floor, 'hostsbehind': hosts, 'canpublish': not hosts, }) @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 def _asset_facts(hostnames): """What ShopDB knows about each reporting host, resolved in bulk. A report row only carries a hostname; everything an operator wants next - which machine that PC drives, which measuring tool it is, where it sits, whether its config is still being backed up - hangs off the asset behind it. Bulk queries, never per row: this table shows the whole fleet, so a lookup inside the loop would be one query per PC. Every plugin here is optional (ADR-013), hence the ImportError guards - a lean build without computers still renders the table, just without these columns. Returns {lowercased hostname: {...}}. """ facts = {} if not hostnames: return facts try: from plugins.computers.models import Computer except ImportError: return facts from shopdb.api import Asset rows = (db.session.query(Computer, Asset) .join(Asset, Asset.assetid == Computer.assetid) .filter(Computer.hostname.in_(hostnames)) .all()) assetidtohost = {} for computer, asset in rows: key = (computer.hostname or '').lower() assetidtohost[asset.assetid] = key facts[key] = { 'computerid': computer.computerid, 'assetid': asset.assetid, 'assetnumber': asset.assetnumber, 'location': (asset.location.locationname if asset.location else None), 'mapx': asset.mapx, 'mapy': asset.mapy, 'machinenumber': None, 'machineassetid': None, 'toolassetnumber': None, 'toolassetid': None, 'displayrole': None, 'backupkind': None, 'backuplastseen': None, } _attach_controlled_assets(facts, assetidtohost) _attach_display_roles(facts, hostnames) _attach_backup_state(facts, hostnames) return facts def _attach_controlled_assets(facts, assetidtohost): """Machine number / measuring-tool asset # for the asset each PC controls. The collector records this as a 'controls' relationship, so read that rather than re-deriving it - the same resolution living in two places is what put a wrong subtype filter on the map. """ if not assetidtohost: return try: from shopdb.api import Asset, AssetRelationship, RelationshipType except ImportError: return controls = RelationshipType.query.filter_by( relationshiptype='controls').first() if not controls: return target = db.aliased(Asset) rows = (db.session.query(AssetRelationship.sourceassetid, target) .join(target, target.assetid == AssetRelationship.targetassetid) .filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid, AssetRelationship.isactive.is_(True), AssetRelationship.sourceassetid.in_(assetidtohost)) .all()) toolassetids = set() try: from plugins.measuringtools.models import MeasuringTool toolassetids = {row.assetid for row in MeasuringTool.query.with_entities(MeasuringTool.assetid)} except ImportError: pass for sourceassetid, asset in rows: entry = facts.get(assetidtohost.get(sourceassetid)) if not entry: continue if asset.assetid in toolassetids: entry['toolassetnumber'] = asset.assetnumber entry['toolassetid'] = asset.assetid else: entry['machinenumber'] = asset.assetnumber entry['machineassetid'] = asset.assetid def _attach_display_roles(facts, hostnames): """Dashboard / Lobby / parts kiosk, from the display-role mapping. ShopDB's mirror of the display-type.txt each kiosk reads at startup. """ from shopdb.api import DashboardDefault lowered = {name.lower() for name in hostnames} for default in DashboardDefault.query.filter_by(isactive=True).all(): host = (default.fqdn or '').split('.')[0].lower() if host and host in lowered and facts.get(host): facts[host]['displayrole'] = default.displayrole def _attach_backup_state(facts, hostnames): """Whether this PC's config is still being backed up, and of what kind. Keyed on lastseenat, NOT the newest revision date. Dedup means an unchanged config writes no revision, so a machine stable for six months has a six-month-old newest revision and is perfectly healthy; the one to worry about is the machine whose backup stopped running. sourcehostname is the PC the config was read off, which is exactly the host reporting here. """ try: from plugins.backups.models import BackupRevision except ImportError: return rows = (BackupRevision.query .filter(BackupRevision.sourcehostname.in_(hostnames)) .all()) # Newest confirmed first, ordered HERE rather than in SQL: putting nulls # last needs ORDER BY ... NULLS LAST, which SQLite accepts and MySQL # rejects outright - so the query passed every test and 500'd on the real # database. Row counts here are one per host per kind, so sorting in Python # costs nothing. rows.sort(key=lambda revision: (revision.lastseenat is not None, revision.lastseenat or _EPOCH), reverse=True) for revision in rows: key = (revision.sourcehostname or '').lower() entry = facts.get(key) # First row per host wins. if entry and entry['backuplastseen'] is None: entry['backupkind'] = revision.backupkind entry['backuplastseen'] = ( revision.lastseenat.isoformat() + 'Z' if revision.lastseenat else None) def _report_stale_cutoff(): """(cutoff datetime, threshold minutes) for 'this PC has gone quiet'. A report is a record of one cycle, not a heartbeat with an expiry: nothing ages it. So a PC that stops reporting keeps the status of its last good cycle and reads as healthy while it is unplugged. Comparing receivedat - the SERVER's clock, not anything the client asserts - against a threshold is what turns silence into a visible state. Returns (None, 0) when the check is disabled, so callers skip it entirely. """ from shopdb.api import Setting try: minutes = int(Setting.get('geenforce_reportstaleminutes', 30) or 0) except (TypeError, ValueError): minutes = 30 if minutes <= 0: return None, 0 # Same clock helper that WROTE receivedat (naive UTC), so the two cannot # drift into comparing an aware datetime against a naive one. return service._utcnow() - timedelta(minutes=minutes), minutes @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() facts = _asset_facts({report.hostname for report in reports if report.hostname}) stalecutoff, stalafter = _report_stale_cutoff() 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] known = facts.get((report.hostname or '').lower(), {}) # Two separate ways to have gone quiet, and the table has a column for # each. receivedat is the SERVER's record of the last report it got; # lastcheckin is the client's own claim about when it last ran. A PC can # fail either way round - silent to the server, or still posting while # its own cycle has stopped advancing - so both count. # # They are checked independently rather than by taking the newer of the # two: lastcheckin is client-asserted, so a wrong or future value there # must not be able to mask real silence on receivedat. isstale = stalecutoff is not None and ( report.receivedat is None or report.receivedat < stalecutoff or (report.lastcheckin is not None and report.lastcheckin < stalecutoff)) data.append({ 'reportid': report.reportid, 'hostname': report.hostname, # A host ShopDB has no asset for still shows: the enforcement result # is real even when the inventory is behind. 'computerid': known.get('computerid'), 'assetnumber': known.get('assetnumber'), 'location': known.get('location'), 'mapx': known.get('mapx'), 'mapy': known.get('mapy'), 'machinenumber': known.get('machinenumber'), 'machineassetid': known.get('machineassetid'), 'toolassetnumber': known.get('toolassetnumber'), 'toolassetid': known.get('toolassetid'), # Reported by the device wins; the DashboardDefault mapping is a # fallback for hosts on an older client that does not send it. 'displayrole': report.subtype or known.get('displayrole'), 'backupkind': known.get('backupkind'), 'backuplastseen': known.get('backuplastseen'), 'scopename': report.scopename, 'phase': report.phase, 'appliedversion': report.appliedversion, 'latestversion': latest, 'receivedlatest': (latest is not None and report.appliedversion == latest), 'enforcerversion': report.enforcerversion, # 'status' is what the PC said about its LAST cycle and is left # exactly as reported. 'isstale' is the server's own judgement that # no cycle has been heard from in too long - a PC switched off after # a clean run reports 'ok' forever, so the two are different facts # and the table shows both. 'status': report.status, 'isstale': isstale, 'staleafterminutes': stalafter, '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], }) @geenforce_bp.route('/dashboard/failures', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') def dashboard_failures(): """Entries that FAILED on their PC's most recent enforcement cycle. The dashboard card behind this answers a question nothing else does: what is broken on the floor right now. The reports table has held it all along - status, the failing entry, its exit code and the engine's message - and the only way to see any of it was to open one PC's report modal, one PC at a time. A bay returned 500 to every collector report for a day and a half before anyone looked. Per ENTRY, not per report: "three PCs failed" is a number, while "Install OpenText failed with exit 1603 on WJSF1234" is something a person can act on. Only current reports (iscurrent) are considered, so a failure that has since been fixed disappears on its own rather than needing dismissing. """ rows = (db.session.query(ManifestEnforcementResult, ManifestEnforcementReport) .join(ManifestEnforcementReport, ManifestEnforcementResult.reportid == ManifestEnforcementReport.reportid) .filter(ManifestEnforcementReport.iscurrent.is_(True), ManifestEnforcementResult.action == 'failed') .order_by(ManifestEnforcementReport.receivedat.desc()) .limit(50) .all()) # Resolve hostnames to computerids in ONE query so the card can link to the # PC rather than just naming it. A row whose PC is unknown to shopdb still # shows - the failure is real even when the inventory is behind. hostnames = {report.hostname for _result, report in rows} idbyhost = {} if hostnames: try: from plugins.computers.models import Computer for computer in Computer.query.filter( Computer.hostname.in_(hostnames)).all(): idbyhost[(computer.hostname or '').lower()] = computer.computerid except ImportError: pass return success_response([{ 'hostname': report.hostname, 'computerid': idbyhost.get((report.hostname or '').lower()), 'scopename': report.scopename, 'entryname': result.entryname, 'exitcode': result.exitcode, 'message': (result.message or '').strip()[:200], 'receivedat': (report.receivedat.isoformat() + 'Z' if report.receivedat else None), } for result, report in rows])