Files
shopdb-flask/migrations/versions/7d30_apitoken_resourcescopes.py
cproudlock 75386d2f51 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.
2026-07-23 09:02:42 -04:00

37 lines
1.2 KiB
Python

"""apitokens.resourcescopes: pin a service token to specific resource scopes.
A geenforce.fetch token handed to a fleet (e.g. displays) should reach only its
own manifest scope(s) and the blobs those scopes ship, not every scope by name
or every blob by hash. This nullable JSON column carries that allowlist; NULL =
unrestricted, so every existing token keeps working unchanged. Idempotent.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d30_apitoken_resourcescopes'
down_revision = '7d29_supportteam_webhookurl'
branch_labels = None
depends_on = None
def _has_column(bind, table, column):
inspector = sa.inspect(bind)
if table not in inspector.get_table_names():
return False
return column in {c['name'] for c in inspector.get_columns(table)}
def upgrade():
bind = op.get_bind()
if not _has_column(bind, 'apitokens', 'resourcescopes'):
op.add_column('apitokens',
sa.Column('resourcescopes', sa.Text(), nullable=True))
def downgrade():
bind = op.get_bind()
if _has_column(bind, 'apitokens', 'resourcescopes'):
op.drop_column('apitokens', 'resourcescopes')