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>
50 lines
1.3 KiB
Python
50 lines
1.3 KiB
Python
"""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')
|