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:
@@ -45,6 +45,24 @@ def _validate_scopes(scopes, owner):
|
||||
return scopes, None
|
||||
|
||||
|
||||
def _validate_resourcescopes(resourcescopes):
|
||||
"""Validate a resourcescopes payload. Return (list_or_none, error_response).
|
||||
|
||||
resourcescopes is a DIFFERENT axis from scopes: it pins the token to a set
|
||||
of resource names (today geenforce manifest scope names, e.g.
|
||||
gea-shopfloor-display), not permission names, so there is no catalog to
|
||||
check against - a plugin owns the meaning. None/absent = unrestricted.
|
||||
"""
|
||||
if resourcescopes is None:
|
||||
return None, None
|
||||
if not isinstance(resourcescopes, list) \
|
||||
or not all(isinstance(s, str) for s in resourcescopes):
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'resourcescopes must be a list of resource-scope names')
|
||||
return [s.strip() for s in resourcescopes if s.strip()], None
|
||||
|
||||
|
||||
@apitokens_bp.route('', methods=['GET'])
|
||||
@jwt_required()
|
||||
def list_apitokens():
|
||||
@@ -93,6 +111,11 @@ def create_apitoken():
|
||||
if scope_error is not None:
|
||||
return scope_error
|
||||
|
||||
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||
data.get('resourcescopes'))
|
||||
if resource_error is not None:
|
||||
return resource_error
|
||||
|
||||
secret = ApiToken.generate_secret()
|
||||
token = ApiToken(
|
||||
userid=current_user.userid,
|
||||
@@ -102,6 +125,7 @@ def create_apitoken():
|
||||
expiresat=expiresat,
|
||||
)
|
||||
token.scopelist = scopelist
|
||||
token.resourcescopelist = resourcescopelist
|
||||
db.session.add(token)
|
||||
db.session.flush()
|
||||
|
||||
@@ -147,6 +171,12 @@ def update_apitoken(tokenid: int):
|
||||
if scope_error is not None:
|
||||
return scope_error
|
||||
token.scopelist = scopelist
|
||||
if 'resourcescopes' in data:
|
||||
resourcescopelist, resource_error = _validate_resourcescopes(
|
||||
data.get('resourcescopes'))
|
||||
if resource_error is not None:
|
||||
return resource_error
|
||||
token.resourcescopelist = resourcescopelist
|
||||
|
||||
db.session.commit()
|
||||
return success_response(token.to_dict(), message='Token updated')
|
||||
|
||||
Reference in New Issue
Block a user