Add optional permission scopes to API tokens
All checks were successful
CI / backend (push) Successful in 1m19s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

A token may carry a scopes list: it then grants only those permissions,
intersected with what the owner holds at use time, with the admin role
bypass suspended and role-gated routes denied - a scoped token from an
admin account is genuinely limited. Scope ceiling enforced at
create/update too (only permissions the owner holds; 400 lists
violations) and the picker only offers what you hold. Token management
itself now requires the new apitokens.create permission (admin by
default, grantable via roles). Unscoped tokens keep the exact prior
act-as-owner behavior; imports need an unscoped admin token.
Migration 7d22.

756 tests pass; live-verified scoped 201/403 matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 08:58:31 -04:00
parent 688ff6646d
commit 848a8fb34f
13 changed files with 728 additions and 45 deletions

View File

@@ -0,0 +1,49 @@
"""API token permission scopes (apitokens.scopes)
Adds a nullable scopes column to apitokens: a JSON array of permission-name
strings. NULL means the token is unscoped and acts with the full authority of
its owner (the original behavior). A scoped token grants ONLY the listed
permissions, intersected with what the owner actually holds, and suspends the
admin-role bypass so a scoped token minted by an admin is genuinely limited.
Idempotent guard so it is safe on a partially-migrated box; real downgrade.
Revision ID: 7d22_apitokens_scopes
Revises: 7d21_apitokens
Create Date: 2026-07-12
"""
from alembic import op
import sqlalchemy as sa
revision = '7d22_apitokens_scopes'
down_revision = '7d21_apitokens'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('apitokens')}
if 'scopes' in columns:
return
op.add_column('apitokens', sa.Column('scopes', sa.Text(), nullable=True))
def downgrade():
bind = op.get_bind()
insp = sa.inspect(bind)
if 'apitokens' not in insp.get_table_names():
return
columns = {c['name'] for c in insp.get_columns('apitokens')}
if 'scopes' not in columns:
return
op.drop_column('apitokens', 'scopes')