Add prebuilt user scopes and guard invalid Reporter grants

A newly created user held no roles and no permissions, so the sidebar filtered every nav item away and the account appeared broken. Adds three prebuilt scopes (Reporter, Operator, Technician) derived from the live permission catalog by verb rather than hand-listed, so a new plugin permission lands in the right scope automatically instead of silently going ungranted. users.view is granted to all three since a user who cannot see the directory cannot collaborate; users.create/edit/delete stay admin-only. Role.seedscopes() is additive and idempotent, seeded via flask seed permissions, and preserves custom grants on rerun.

Reporter guard: the auth model is additive across admin, direct grants and roles, so Reporter cannot be expressed as merely fewer grants - a direct endpoint-action grant would silently re-arm a read-only account. Rather than change the global permission predicate and alter every route's semantics, _check_scope_grants() rejects the invalid combination at write time on both create and update. The update path validates the effective post-update state, so demoting a Technician to Reporter is refused while a stale endpoint grant remains rather than quietly leaving a read-only user with write authority.

Validation runs before the User object is constructed. Assigning user.roles first put the relationship into the session autoflush path while the User was still unattached; nothing broke because the refusal returns before db.session.add(), but it left a trap where any query added below that point would autoflush a half-built user and surface later as an orphan row. Verified with -W error::sqlalchemy.exc.SAWarning: 25 passed.
This commit is contained in:
benjamin.baker
2026-09-07 12:39:24 -04:00
parent 52c9d2b009
commit 0e43ac6b8d
9 changed files with 1107 additions and 17 deletions

View File

@@ -0,0 +1,59 @@
"""Per-user direct permission grants.
Permissions were reachable only through roles. At 5-7 operators per instance
that forces a role to be minted for every one-off capability - "Bob, and only
Bob, may push scripts" - until the role list is a mixture of job titles and
single-capability flags and nobody can tell which is which.
A row here is an ORDINARY permission grant. It is identical in power to one held
through a role: it counts toward the API token scope ceiling, it is folded by
both User.haspermission() and User.getpermissions(), and it is revoked the same
way. There is deliberately no second class of permission.
Nothing is granted by this migration. The table is created empty; existing
authority is untouched.
Revision ID: 7d43_userpermissions
Revises: 7d42_split_endpoints_deploy
"""
from alembic import op
import sqlalchemy as sa
revision = '7d43_userpermissions'
down_revision = '7d42_split_endpoints_deploy'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'userpermissions' in set(inspector.get_table_names()):
return
op.create_table(
'userpermissions',
sa.Column('userid', sa.Integer(), nullable=False),
sa.Column('permissionid', sa.Integer(), nullable=False),
# Cascade on both sides: a direct grant is meaningless once either end
# is gone, and leaving orphans would let a recycled id silently inherit
# someone else's capability.
sa.ForeignKeyConstraint(['userid'], ['users.userid'],
ondelete='CASCADE'),
sa.ForeignKeyConstraint(['permissionid'],
['permissions.permissionid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('userid', 'permissionid'),
)
op.create_index('idx_userpermissions_userid', 'userpermissions', ['userid'])
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'userpermissions' not in set(inspector.get_table_names()):
return
op.drop_index('idx_userpermissions_userid', table_name='userpermissions')
op.drop_table('userpermissions')