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.
This commit is contained in:
cproudlock
2026-07-23 09:02:42 -04:00
parent d0bf37ced7
commit 75386d2f51
15 changed files with 344 additions and 25 deletions

View File

@@ -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):