From 75386d2f51cffc3ab3dfb6e879155d7fcab4448a Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 23 Jul 2026 09:02:42 -0400 Subject: [PATCH] geenforce: resource-scope binding for fetch tokens (0.15.0) A geenforce.fetch token can now be pinned to specific manifest scopes so a fleet-wide key (a display's, delivered by DSC or baked into the image) is not a skeleton key for the whole content store. NULL binding = unrestricted, so every existing service token keeps working. Core: - ApiToken.resourcescopes column + resourcescopelist property (migration 7d30_apitoken_resourcescopes; NULL = unrestricted). - apitokens API create/update accept + persist an optional resourcescopes list (a resource-name allowlist; not permission-catalog names). - New contract helper authorized_service_token(scope): same check as service_token_authorized but returns the ApiToken so a plugin can read its binding. Contract 0.14.0 -> 0.15.0; also export SupportTeam. GE-Enforce enforcement: - get_manifest: a bound token requesting a scope outside its allowlist -> 403. - get_payload: a bound token may only pull a blob its own scope(s) reference (service.blob_referenced_by_scopes); anything else -> 404 (no hash probing). - Decorator stashes the authorized token on g for the route to read. Also fixes a pre-existing contract-surface violation: the printers/printedparts alert helpers imported shopdb.core.models / shopdb.extensions directly; now via shopdb.api (SupportTeam newly exported). Docs: GE-ENFORCE-DISPLAY.md provisioning note, PLUGIN-HOOKS.md, CLAUDE.md. 9 new resource-binding tests; full suite 1131 passing. --- CLAUDE.md | 2 +- docs/GE-ENFORCE-DISPLAY.md | 21 ++- docs/PLUGIN-HOOKS.md | 10 +- .../versions/7d30_apitoken_resourcescopes.py | 36 +++++ plugins/geenforce/api/routes.py | 33 ++++- plugins/geenforce/service.py | 29 ++++ plugins/printedparts/api/routes.py | 2 +- plugins/printers/models/supply_alert.py | 3 +- plugins/printers/services/supply_alerts.py | 2 +- shopdb/__init__.py | 2 +- shopdb/api/__init__.py | 7 +- shopdb/core/api/apitokens.py | 30 ++++ shopdb/core/models/apitoken.py | 27 ++++ shopdb/utils/apitoken_auth.py | 37 +++-- .../test_geenforce_resource_binding.py | 128 ++++++++++++++++++ 15 files changed, 344 insertions(+), 25 deletions(-) create mode 100644 migrations/versions/7d30_apitoken_resourcescopes.py create mode 100644 tests/test_plugins/test_geenforce_resource_binding.py diff --git a/CLAUDE.md b/CLAUDE.md index 5a3f1a3..571b3cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ Refactor phases 0-5 landed; phase 6 (multi-site distribution readiness) largely ### Active state - 1077 tests, naming/style check green, Gitea Actions CI (backend + naming + frontend build + a lean-build job + a migrations-mysql job that runs the real fresh upgrade on utf8mb4 MySQL 8) -- `__contract_version__` at 0.14.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) +- `__contract_version__` at 0.15.0 (0.12.0 mailer, 0.13.0 User/Role, 0.14.0 send_webhook, 0.15.0 authorized_service_token) (product `__version__` 0.7.0, tags v0.5.0/v0.6.0/v0.7.0 - distinct series, ADR-007) - 13 bundled plugins all satisfy contract: computers, employees, geenforce, knowledgebase, machines, measuringtools, network, notifications, printedparts, printers, slides, usb, warranty - Core Alembic chain: baseline `68b3947ae14f` -> head `7d26_settings_description_text` (33 core migrations). Each plugin owns its own chain (ADR-008); deploy runs `flask db upgrade` then `flask plugin upgrade-all`. Reproducible + idempotent from empty (env.py relaxes session sql_mode so the chain runs on strict MySQL 8). - Lean per-site builds (ADR-013 + ADR-014): `scripts/build-site.sh` (backend) + `SITE_PLUGINS` via `scripts/stage-frontend.mjs` (frontend) ship only chosen plugins; `flask plugin prune-schema` drops non-installed plugins' tables at provisioning. Sidebar nav / settings / Displays all gate on staged routes. Manifest-less `plugins//frontend/` dirs (e.g. `applications`) are core and always ship. diff --git a/docs/GE-ENFORCE-DISPLAY.md b/docs/GE-ENFORCE-DISPLAY.md index b0f166b..515a03a 100644 --- a/docs/GE-ENFORCE-DISPLAY.md +++ b/docs/GE-ENFORCE-DISPLAY.md @@ -1,6 +1,6 @@ # GE-Enforce: the gea-shopfloor-display scope -Displays are the share-less corner of the fleet. They are Intune/Entra-joined, +Displays are the share-less corner of the fleet. They are Entra-joined, credential-less kiosk PCs that pull their manifest over HTTPS on port 443 and authenticate with a read-only service PAT scoped `geenforce.fetch`, sent as `X-API-Key`. They have no SMB share mount. The kiosk engine and the kiosk @@ -8,6 +8,25 @@ browser are baked into the display image, not shipped over HTTPS, so the display manifest heals POLICY / CONFIG drift only, never EXEs. It is self-sufficient and does not inherit the fleet-wide `common` scope (see below). +## The display fetch token MUST be resource-bound + +The same read-only key ships to every display (delivered by DSC, or baked into +the image), so it must not be a skeleton key for the whole content store. Mint +the display token bound to just this scope, so a leak cannot pull any other +scope's manifest or any blob by hash: + +``` +POST /api/apitokens +{ "name": "display fetch", "scopes": ["geenforce.fetch"], + "resourcescopes": ["gea-shopfloor-display"] } +``` + +With `resourcescopes` set, `GET /manifest?pctype=` returns 403 and +`GET /payload/` returns 404 for any blob the display scope does not +reference. `resourcescopes` NULL (unset) = unrestricted, for back-compat with +existing service tokens. Rotate by minting a new bound token and revoking the +old one (deactivate it server-side); DSC re-delivers, or re-image. + There are three display subtypes, selected by `C:\Enrollment\display-type.txt`: `Dashboard`, `Lobby`, and `3DPrintRoom`. diff --git a/docs/PLUGIN-HOOKS.md b/docs/PLUGIN-HOOKS.md index e1a84ec..6b81979 100644 --- a/docs/PLUGIN-HOOKS.md +++ b/docs/PLUGIN-HOOKS.md @@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra The framework declares its contract version in `shopdb/__init__.py`: ```python -__contract_version__ = '0.14.0' +__contract_version__ = '0.15.0' ``` Each plugin's `manifest.json` declares the range of contract versions it supports: @@ -497,6 +497,12 @@ What `shopdb.api` exposes: (`service_token_authorized(scope)` returns True when the request carries a managed service token scoped for `scope` whose owner holds that permission - for unattended plugin endpoints like the GE-Enforce fetch API) +- `authorized_service_token(scope)` (0.15.0) - same check as + `service_token_authorized` but returns the `ApiToken` itself (or None), so a + plugin can honor the token's optional resource binding + (`token.resourcescopelist`: an allowlist of resource names the token may + reach, NULL = unrestricted). GE-Enforce uses it to pin a display's fetch + token to its own manifest scope + that scope's blobs. - Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`, `dualpath_single_machine_enabled` - Import mode: `apply_import_timestamps`, `import_mode_active`, @@ -506,6 +512,8 @@ What `shopdb.api` exposes: `cmmc_usb_connection` - `User` / `Role` (0.13.0) - the account and role models, e.g. resolving alert recipients' emails from selected user ids or role membership +- `SupportTeam` (0.15.0) - the support-team model (carries a `webhookurl`), so + an alerting plugin can route a notification to a chosen team's Teams webhook - Mailer (0.12.0): `send_email(to, subject, html, text=None)` and `send_alert(subject, html, text=None)` - settings-first, no-op safe when email is unconfigured; send_alert targets the site's alert_recipients diff --git a/migrations/versions/7d30_apitoken_resourcescopes.py b/migrations/versions/7d30_apitoken_resourcescopes.py new file mode 100644 index 0000000..707fbf9 --- /dev/null +++ b/migrations/versions/7d30_apitoken_resourcescopes.py @@ -0,0 +1,36 @@ +"""apitokens.resourcescopes: pin a service token to specific resource scopes. + +A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its +own manifest scope(s) and the blobs those scopes ship, not every scope by name +or every blob by hash. This nullable JSON column carries that allowlist; NULL = +unrestricted, so every existing token keeps working unchanged. Idempotent. +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7d30_apitoken_resourcescopes' +down_revision = '7d29_supportteam_webhookurl' +branch_labels = None +depends_on = None + + +def _has_column(bind, table, column): + inspector = sa.inspect(bind) + if table not in inspector.get_table_names(): + return False + return column in {c['name'] for c in inspector.get_columns(table)} + + +def upgrade(): + bind = op.get_bind() + if not _has_column(bind, 'apitokens', 'resourcescopes'): + op.add_column('apitokens', + sa.Column('resourcescopes', sa.Text(), nullable=True)) + + +def downgrade(): + bind = op.get_bind() + if _has_column(bind, 'apitokens', 'resourcescopes'): + op.drop_column('apitokens', 'resourcescopes') diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index 5bfcbf4..ca545f2 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -12,13 +12,13 @@ import os import time from functools import wraps -from flask import Blueprint, request, Response, send_file, current_app +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, - service_token_authorized, Setting, Application, + authorized_service_token, Setting, Application, ) SHAREROOT_SETTING = 'geenforce_share_root' @@ -51,7 +51,12 @@ def _require_service_token(scope): def wrapper(f): @wraps(f) def decorated(*args, **kwargs): - if service_token_authorized(scope): + 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) return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key', http_code=401) @@ -59,6 +64,14 @@ def _require_service_token(scope): 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) @@ -80,6 +93,11 @@ def get_manifest(): 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: @@ -178,6 +196,15 @@ def get_payload(sha256): 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}) diff --git a/plugins/geenforce/service.py b/plugins/geenforce/service.py index 8476760..eff69f2 100644 --- a/plugins/geenforce/service.py +++ b/plugins/geenforce/service.py @@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation: """ import hashlib +import json import os import tempfile from datetime import datetime, timezone @@ -288,6 +289,34 @@ def blob_path(sha256): return os.path.join(_payload_dir(), sha256) +def blob_referenced_by_scopes(sha256, scopenames): + """True when any of these scopes' CURRENT published manifest references + sha256 as an entry payload. + + Backs the resource-bound token check on GET /payload: a token pinned to its + own scope(s) may only pull blobs those scopes actually ship, not any blob by + hash. Empty scopenames -> False (a bound-but-empty token reaches nothing). + """ + if not scopenames: + return False + rows = db.session.query(ManifestPublishedVersion).join( + ManifestScope, + ManifestPublishedVersion.scopeid == ManifestScope.scopeid, + ).filter( + ManifestScope.scopename.in_(list(scopenames)), + ManifestPublishedVersion.iscurrent == True, # noqa: E712 + ).all() + for row in rows: + try: + doc = json.loads(row.manifestjson) + except (ValueError, TypeError): + continue + for entry in doc.get('Applications', []) or []: + if entry.get('PayloadSha256') == sha256: + return True + return False + + def store_blob(rawbytes, filename, contenttype=None): """Store bytes in the content-addressed payload store; return the sha256. diff --git a/plugins/printedparts/api/routes.py b/plugins/printedparts/api/routes.py index 98f357a..877244e 100644 --- a/plugins/printedparts/api/routes.py +++ b/plugins/printedparts/api/routes.py @@ -380,7 +380,7 @@ def _alert_team_webhook(): if not team_id: return None try: - from shopdb.core.models import SupportTeam + from shopdb.api import SupportTeam team = db.session.get(SupportTeam, int(team_id)) except (ValueError, TypeError): return None diff --git a/plugins/printers/models/supply_alert.py b/plugins/printers/models/supply_alert.py index 5f2ca82..de7a5a6 100644 --- a/plugins/printers/models/supply_alert.py +++ b/plugins/printers/models/supply_alert.py @@ -7,8 +7,7 @@ printer + supply. One row per (printerid, supplykey); supplykey is the toner color (black/cyan/magenta/yellow) or the raw item name when color is unknown. """ -from shopdb.extensions import db -from shopdb.core.models.base import BaseModel +from shopdb.api import db, BaseModel class PrinterSupplyAlert(BaseModel): diff --git a/plugins/printers/services/supply_alerts.py b/plugins/printers/services/supply_alerts.py index 0bb8ab6..5f1ba94 100644 --- a/plugins/printers/services/supply_alerts.py +++ b/plugins/printers/services/supply_alerts.py @@ -85,7 +85,7 @@ def alert_team_webhook(): if not team_id: return None try: - from shopdb.core.models import SupportTeam + from shopdb.api import SupportTeam team = db.session.get(SupportTeam, int(team_id)) except (ValueError, TypeError): return None diff --git a/shopdb/__init__.py b/shopdb/__init__.py index 2426774..ecb9b91 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -36,7 +36,7 @@ from .plugins import plugin_manager # unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped # managed service token without importing core token internals. Additive name # on the import surface, minor bump. -__contract_version__ = '0.14.0' +__contract_version__ = '0.15.0' # Product release version (see ADR-007). The product version and the # plugin-contract version above are distinct series with independent diff --git a/shopdb/api/__init__.py b/shopdb/api/__init__.py index 57cb26a..056b25a 100644 --- a/shopdb/api/__init__.py +++ b/shopdb/api/__init__.py @@ -46,6 +46,7 @@ from shopdb.core.models import ( RelationshipType, User, Role, + SupportTeam, ) # Response + pagination helpers for plugin API blueprints @@ -63,7 +64,9 @@ from shopdb.utils.authz import require_permission, require_role # Service-token authorization for unattended plugin endpoints (collector, # GE-Enforce fetch, ...): checks a scoped managed token without exposing token # internals. -from shopdb.utils.apitoken_auth import service_token_authorized +from shopdb.utils.apitoken_auth import ( + service_token_authorized, authorized_service_token, +) # Import-mode helpers: preserve legacy timestamps during a bulk data import from shopdb.utils.import_mode import ( @@ -263,6 +266,8 @@ __all__ = [ 'require_permission', 'require_role', 'service_token_authorized', + 'authorized_service_token', + 'SupportTeam', # Import-mode helpers 'apply_import_timestamps', 'import_mode_active', diff --git a/shopdb/core/api/apitokens.py b/shopdb/core/api/apitokens.py index c06b5c7..484bf84 100644 --- a/shopdb/core/api/apitokens.py +++ b/shopdb/core/api/apitokens.py @@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner): return scopes, None +def _validate_resourcescopes(resourcescopes): + """Validate a resourcescopes payload. Return (list_or_none, error_response). + + resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set + of resource names (today geenforce manifest scope names, e.g. + gea-shopfloor-display), not permission names, so there is no catalog to + check against - a plugin owns the meaning. None/absent = unrestricted. + """ + if resourcescopes is None: + return None, None + if not isinstance(resourcescopes, list) \ + or not all(isinstance(s, str) for s in resourcescopes): + return None, error_response( + ErrorCodes.VALIDATION_ERROR, + 'resourcescopes must be a list of resource-scope names') + return [s.strip() for s in resourcescopes if s.strip()], None + + @apitokens_bp.route('', methods=['GET']) @jwt_required() def list_apitokens(): @@ -93,6 +111,11 @@ def create_apitoken(): if scope_error is not None: return scope_error + resourcescopelist, resource_error = _validate_resourcescopes( + data.get('resourcescopes')) + if resource_error is not None: + return resource_error + secret = ApiToken.generate_secret() token = ApiToken( userid=current_user.userid, @@ -102,6 +125,7 @@ def create_apitoken(): expiresat=expiresat, ) token.scopelist = scopelist + token.resourcescopelist = resourcescopelist db.session.add(token) db.session.flush() @@ -147,6 +171,12 @@ def update_apitoken(tokenid: int): if scope_error is not None: return scope_error token.scopelist = scopelist + if 'resourcescopes' in data: + resourcescopelist, resource_error = _validate_resourcescopes( + data.get('resourcescopes')) + if resource_error is not None: + return resource_error + token.resourcescopelist = resourcescopelist db.session.commit() return success_response(token.to_dict(), message='Token updated') diff --git a/shopdb/core/models/apitoken.py b/shopdb/core/models/apitoken.py index 86ff501..9d5a39e 100644 --- a/shopdb/core/models/apitoken.py +++ b/shopdb/core/models/apitoken.py @@ -53,6 +53,13 @@ class ApiToken(BaseModel): # authority). A scoped token grants ONLY these, intersected with what the # owner holds, and suspends the admin bypass. See scopelist below. scopes = db.Column(db.Text, nullable=True) + # JSON array of RESOURCE-scope names (a different axis from `scopes`, which + # is what the token may DO). Today these are geenforce manifest scope names + # (e.g. gea-shopfloor-display): a resource-bound geenforce.fetch token may + # only pull those scopes' manifests and only blobs those manifests + # reference. NULL = unrestricted (any resource), for back-compat. See + # resourcescopelist below. + resourcescopes = db.Column(db.Text, nullable=True) user = db.relationship('User', backref=db.backref('apitokens', lazy='dynamic')) @@ -98,6 +105,25 @@ class ApiToken(BaseModel): else: self.scopes = json.dumps(list(names)) + @property + def resourcescopelist(self): + """Parsed resource-scope names, or None when the token is unrestricted.""" + if self.resourcescopes is None: + return None + try: + value = json.loads(self.resourcescopes) + except (ValueError, TypeError): + return None + return value if isinstance(value, list) else None + + @resourcescopelist.setter + def resourcescopelist(self, names): + """Store a resource-scope list, or None to clear the restriction.""" + if names is None: + self.resourcescopes = None + else: + self.resourcescopes = json.dumps(list(names)) + @staticmethod def unknown_scope_names(names) -> list: """Return the subset of names that are not in the permission catalog. @@ -119,6 +145,7 @@ class ApiToken(BaseModel): 'expiresat': self.expiresat.isoformat() + 'Z' if self.expiresat else None, 'lastusedat': self.lastusedat.isoformat() + 'Z' if self.lastusedat else None, 'scopes': self.scopelist, + 'resourcescopes': self.resourcescopelist, 'isactive': self.isactive, 'isexpired': self.is_expired, 'createddate': self.createddate.isoformat() + 'Z' if self.createddate else None, diff --git a/shopdb/utils/apitoken_auth.py b/shopdb/utils/apitoken_auth.py index 99ae203..ceb6b63 100644 --- a/shopdb/utils/apitoken_auth.py +++ b/shopdb/utils/apitoken_auth.py @@ -76,16 +76,13 @@ def touch_apitoken_lastused(token): db.session.commit() -def service_token_authorized(scope): - """True when the current request carries a managed token scoped for `scope` - whose owner is active and holds that permission. Accepts X-API-Key or a - Bearer PAT (the before_request shim resolves Bearer into g.apitokenid). - Touches lastusedat on success. +def authorized_service_token(scope): + """Return the ApiToken authorizing this request for `scope`, or None. - The single contract-surface entry point for unattended SERVICE tokens - (collector.ingest, geenforce.fetch, ...), so plugins authorize a service - token without reaching into core token internals. Returns False on any - miss; the caller returns its own 401. + Same checks as service_token_authorized (managed token scoped for `scope`, + active owner holding the permission), but hands back the token itself so a + caller can read its resource binding (token.resourcescopelist) without + reaching into core token internals. Touches lastusedat on success. """ from shopdb.core.models import User @@ -99,15 +96,29 @@ def service_token_authorized(scope): if tokenid is not None: token = db.session.get(ApiToken, tokenid) if token is None: - return False + return None scopelist = token.scopelist if not scopelist or scope not in scopelist: - return False + return None user = db.session.get(User, token.userid) if user is None or not user.isactive or not user.haspermission(scope): - return False + return None touch_apitoken_lastused(token) - return True + return token + + +def service_token_authorized(scope): + """True when the current request carries a managed token scoped for `scope` + whose owner is active and holds that permission. Accepts X-API-Key or a + Bearer PAT (the before_request shim resolves Bearer into g.apitokenid). + Touches lastusedat on success. + + The single contract-surface entry point for unattended SERVICE tokens + (collector.ingest, geenforce.fetch, ...), so plugins authorize a service + token without reaching into core token internals. Returns False on any + miss; the caller returns its own 401. + """ + return authorized_service_token(scope) is not None def install_apitoken_auth(app): diff --git a/tests/test_plugins/test_geenforce_resource_binding.py b/tests/test_plugins/test_geenforce_resource_binding.py new file mode 100644 index 0000000..41b11ab --- /dev/null +++ b/tests/test_plugins/test_geenforce_resource_binding.py @@ -0,0 +1,128 @@ +"""Resource-scope binding on geenforce fetch tokens. + +A geenforce.fetch token MAY be pinned to specific manifest scopes +(ApiToken.resourcescopes). A bound token can only pull those scopes' manifests +and only the blobs those scopes reference; an unbound token (NULL) keeps the +original behavior. This is what lets a fleet-wide display key be handed out +without exposing every scope + every blob in the content store. +""" + +import hashlib + +from plugins.geenforce import service + +DISPLAY = 'gea-shopfloor-display' +OTHER = 'gea-shopfloor-cmm' + +KIOSK_BYTES = b'kiosk-installer-payload-bytes' +KIOSK_SHA = hashlib.sha256(KIOSK_BYTES).hexdigest() + + +def _publish_display_scope(app): + """Publish a display scope whose one entry references KIOSK_SHA over http.""" + with app.app_context(): + service.store_blob(KIOSK_BYTES, 'kiosk.exe', 'application/octet-stream') + manifest = { + 'Version': '2.0', + 'Applications': [ + {'Name': 'Kiosk', 'Type': 'EXE', 'Installer': 'apps/kiosk.exe', + 'DetectionMethod': 'Always', + 'PayloadSource': 'http', 'PayloadSha256': KIOSK_SHA}, + ], + } + service.replace_scope_draft(DISPLAY, 'runtime', manifest) + service.publish_scope(DISPLAY, 'runtime', notes='display') + # A second, unrelated scope + blob the display token must NOT reach. + service.store_blob(b'secret-other-scope-bytes', 'other.exe') + service.replace_scope_draft(OTHER, 'runtime', { + 'Version': '2.6', + 'Applications': [{'Name': 'Beta', 'Type': 'PS1', + 'Script': 'scripts/beta.ps1', + 'DetectionMethod': 'Always'}]}) + service.publish_scope(OTHER, 'runtime', notes='other') + service.db.session.commit() + + +def _mint(client, auth_headers, resourcescopes=None): + payload = {'name': 'display svc', 'scopes': ['geenforce.fetch']} + if resourcescopes is not None: + payload['resourcescopes'] = resourcescopes + resp = client.post('/api/apitokens', json=payload, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + return resp.get_json()['data'] + + +def _key(client, auth_headers, resourcescopes=None): + return {'X-API-Key': _mint(client, auth_headers, resourcescopes)['secret']} + + +# -- manifest binding --------------------------------------------------------- + +def test_bound_token_gets_its_own_scope(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', headers=headers) + assert resp.status_code == 200 + + +def test_bound_token_forbidden_other_scope(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/manifest?pctype={OTHER}', headers=headers) + assert resp.status_code == 403 + + +def test_unbound_token_reaches_any_scope(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers) # no resourcescopes -> unrestricted + assert client.get(f'/api/geenforce/manifest?pctype={DISPLAY}', + headers=headers).status_code == 200 + assert client.get(f'/api/geenforce/manifest?pctype={OTHER}', + headers=headers).status_code == 200 + + +# -- payload binding ---------------------------------------------------------- + +def test_bound_token_gets_referenced_blob(client, app, db, auth_headers): + _publish_display_scope(app) + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/payload/{KIOSK_SHA}', headers=headers) + assert resp.status_code == 200 + assert resp.data == KIOSK_BYTES + + +def test_bound_token_404_on_unreferenced_blob(client, app, db, auth_headers): + _publish_display_scope(app) + # A blob the display scope does NOT reference (belongs to OTHER). + other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest() + headers = _key(client, auth_headers, resourcescopes=[DISPLAY]) + resp = client.get(f'/api/geenforce/payload/{other_sha}', headers=headers) + assert resp.status_code == 404 + + +def test_unbound_token_gets_any_blob(client, app, db, auth_headers): + _publish_display_scope(app) + other_sha = hashlib.sha256(b'secret-other-scope-bytes').hexdigest() + headers = _key(client, auth_headers) + assert client.get(f'/api/geenforce/payload/{other_sha}', + headers=headers).status_code == 200 + + +# -- token model / API -------------------------------------------------------- + +def test_create_persists_resourcescopes(client, app, db, auth_headers): + data = _mint(client, auth_headers, resourcescopes=[DISPLAY]) + assert data['resourcescopes'] == [DISPLAY] + + +def test_resourcescopes_null_by_default(client, app, db, auth_headers): + data = _mint(client, auth_headers) + assert data['resourcescopes'] is None + + +def test_bad_resourcescopes_rejected(client, app, db, auth_headers): + resp = client.post('/api/apitokens', + json={'name': 'x', 'scopes': ['geenforce.fetch'], + 'resourcescopes': 'not-a-list'}, + headers=auth_headers) + assert resp.status_code == 400