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:
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal file
128
tests/test_plugins/test_geenforce_resource_binding.py
Normal 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
|
||||
Reference in New Issue
Block a user