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

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

View File

@@ -8,6 +8,7 @@ Kept out of the CLI and routes so both share one implementation:
"""
import hashlib
import json
import os
import tempfile
from datetime import datetime, timezone
@@ -288,6 +289,34 @@ def blob_path(sha256):
return os.path.join(_payload_dir(), sha256)
def blob_referenced_by_scopes(sha256, scopenames):
"""True when any of these scopes' CURRENT published manifest references
sha256 as an entry payload.
Backs the resource-bound token check on GET /payload: a token pinned to its
own scope(s) may only pull blobs those scopes actually ship, not any blob by
hash. Empty scopenames -> False (a bound-but-empty token reaches nothing).
"""
if not scopenames:
return False
rows = db.session.query(ManifestPublishedVersion).join(
ManifestScope,
ManifestPublishedVersion.scopeid == ManifestScope.scopeid,
).filter(
ManifestScope.scopename.in_(list(scopenames)),
ManifestPublishedVersion.iscurrent == True, # noqa: E712
).all()
for row in rows:
try:
doc = json.loads(row.manifestjson)
except (ValueError, TypeError):
continue
for entry in doc.get('Applications', []) or []:
if entry.get('PayloadSha256') == sha256:
return True
return False
def store_blob(rawbytes, filename, contenttype=None):
"""Store bytes in the content-addressed payload store; return the sha256.

View File

@@ -380,7 +380,7 @@ def _alert_team_webhook():
if not team_id:
return None
try:
from shopdb.core.models import SupportTeam
from shopdb.api import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None

View File

@@ -7,8 +7,7 @@ printer + supply. One row per (printerid, supplykey); supplykey is the toner
color (black/cyan/magenta/yellow) or the raw item name when color is unknown.
"""
from shopdb.extensions import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
class PrinterSupplyAlert(BaseModel):

View File

@@ -85,7 +85,7 @@ def alert_team_webhook():
if not team_id:
return None
try:
from shopdb.core.models import SupportTeam
from shopdb.api import SupportTeam
team = db.session.get(SupportTeam, int(team_id))
except (ValueError, TypeError):
return None