Files
shopdb-flask/docs/GE-ENFORCE-DISPLAY.md
cproudlock 75386d2f51 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.
2026-07-23 09:02:42 -04:00

4.6 KiB

GE-Enforce: the gea-shopfloor-display scope

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 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=<other> returns 403 and GET /payload/<sha> 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.

Authoring the scope

The scope is authored programmatically by plugins/geenforce/seed_display_scope.py, which builds a manifest dict and hands it to service.replace_scope_draft (the same call the import-share CLI uses), then attaches the inline dispatcher payload. From a Flask app context:

from plugins.geenforce.seed_display_scope import seed_display_scope
seed_display_scope(publish=True)   # publish=False leaves it as a draft

replace_scope_draft is an idempotent draft rebuild. publish=True additionally freezes an immutable published snapshot (that step is not idempotent: it always creates a new version).

What the scope contains

  1. Four Registry drift-heal entries that re-assert the Microsoft Edge kiosk relaunch policies set at imaging by 09-Setup-Display.ps1. Each writes the value and detects drift with DetectionMethod = ValueMatches against the same path/name, so a display that loses a policy self-heals on the next enforce cycle with no keyboard or mouse on site:
    • RelaunchNotification = 2 (DWord, Required auto-restart)
    • RelaunchNotificationPeriod = 3600000 (DWord, 1 hour)
    • RelaunchHeadsUpPeriod = 60000 (DWord, 1 minute)
    • RelaunchWindow (String, JSON, 02:00 start, 120 minute duration)
  2. One PS1 dispatcher, delivered inline over HTTPS. It reads C:\Enrollment\display-type.txt and launches the kiosk target for the subtype. The subtype -> route map is a data-driven table (DISPLAY_TYPE_TARGETS) at the top of both the seed module and the generated script, so targets are easy to edit. DetectionMethod = Always so it re-asserts each cycle, but the script is idempotent (it skips relaunch if a kiosk process is already serving the target URL).

display-type -> target map

display-type.txt kiosk route notes
Dashboard /shopfloor core ShopfloorDashboard, standalone full-screen
Lobby /tv slides plugin TV dashboard (surface lobby)
3DPrintRoom /parts-kiosk PLACEHOLDER, TODO-confirm printedparts parts kiosk route; confirm the real 3D-print-room target with the floor team before publishing to production displays

Self-sufficient: displays do NOT inherit common

The gea-shopfloor-display scope carries everything a display enforces. It does NOT inherit the fleet-wide common scope. Displays run the enforcer with common-merge off (the client default; common-merge is opt-in via Invoke-ShopdbEnforce.ps1 -IncludeCommon), so common's SMB-backed fleet entries (Adobe, Oracle, OpenText, Defect Tracker, EventSaver, printer map, self-update, asset-reporting, ...) never reach a share-less display.

This was a deliberate decision: a display needs none of common's software, and inheriting common would have forced repackaging every SMB common payload as http/inline for a share-less box. Keeping the display scope self-sufficient avoids all of that. If a future non-display share-less PC genuinely needs the fleet-wide entries, that is what -IncludeCommon plus a per-entry SMB->http payload conversion would be for -- but displays do not use it.