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:
@@ -12,13 +12,13 @@ import os
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response, send_file, current_app
|
||||
from flask import Blueprint, request, Response, send_file, current_app, g
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, cache, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
authorized_service_token, Setting, Application,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
@@ -51,7 +51,12 @@ def _require_service_token(scope):
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(scope):
|
||||
token = authorized_service_token(scope)
|
||||
if token is not None:
|
||||
# Stash for the route so it can honor the token's resource
|
||||
# binding (token.resourcescopelist restricts which manifest
|
||||
# scopes + blobs this token may pull; None = unrestricted).
|
||||
g.geenforce_token = token
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
@@ -59,6 +64,14 @@ def _require_service_token(scope):
|
||||
return wrapper
|
||||
|
||||
|
||||
def _token_resource_scopes():
|
||||
"""Resource-scope allowlist for the authorized token, or None if the token
|
||||
may reach any scope. A bound token (e.g. a display's) is pinned to its own
|
||||
manifest scope(s) so a leaked key cannot pull every scope's manifest+blobs."""
|
||||
token = getattr(g, 'geenforce_token', None)
|
||||
return token.resourcescopelist if token is not None else None
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
@@ -80,6 +93,11 @@ def get_manifest():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and scopename not in allowed:
|
||||
return error_response(ErrorCodes.FORBIDDEN,
|
||||
'token is not allowed this scope', http_code=403)
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
@@ -178,6 +196,15 @@ def get_payload(sha256):
|
||||
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
|
||||
http_code=400)
|
||||
|
||||
# A resource-bound token may only pull a blob its own scope(s) reference.
|
||||
# Return 404 (not 403) so it cannot probe which hashes exist. Checked before
|
||||
# the 304 shortcut so a bound token cannot even confirm a hash via ETag.
|
||||
allowed = _token_resource_scopes()
|
||||
if allowed is not None and not service.blob_referenced_by_scopes(sha, allowed):
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'no such payload',
|
||||
http_code=404)
|
||||
|
||||
etag = f'"{sha}"'
|
||||
if request.headers.get('If-None-Match') == etag:
|
||||
return Response(status=304, headers={'ETag': etag})
|
||||
|
||||
Reference in New Issue
Block a user