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

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