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

@@ -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,