feat: granular RBAC system with user groups, deny-list, permission registry, system-admin, self-mod prevention
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""RBAC: groups, user_groups, system_admin, tenant-scoped role_id, denied_permissions.
|
||||
|
||||
Revision ID: 0019_rbac_groups
|
||||
Revises: 0018_fix_notif
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID
|
||||
|
||||
revision = "0019_rbac_groups"
|
||||
down_revision = "0018_fix_notif"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ── groups table ──
|
||||
op.create_table(
|
||||
"groups",
|
||||
sa.Column("id", PGUUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.String(500), nullable=True),
|
||||
sa.Column("permissions", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("denied_permissions", JSONB, nullable=False, server_default="[]"),
|
||||
sa.Column("field_permissions", JSONB, nullable=False, server_default="{}"),
|
||||
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.UniqueConstraint("tenant_id", "name", name="uq_groups_tenant_name"),
|
||||
)
|
||||
|
||||
# ── user_groups table ──
|
||||
op.create_table(
|
||||
"user_groups",
|
||||
sa.Column("user_id", PGUUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("group_id", PGUUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("tenant_id", PGUUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("user_id", "group_id", "tenant_id", name="uq_user_groups_user_group_tenant"),
|
||||
)
|
||||
|
||||
# ── users: add is_system_admin ──
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("is_system_admin", sa.Boolean, nullable=False, server_default="false"),
|
||||
)
|
||||
|
||||
# ── user_tenants: add role_id ──
|
||||
op.add_column(
|
||||
"user_tenants",
|
||||
sa.Column("role_id", PGUUID(as_uuid=True), sa.ForeignKey("roles.id", ondelete="SET NULL"), nullable=True),
|
||||
)
|
||||
op.create_index("ix_user_tenants_role_id", "user_tenants", ["role_id"])
|
||||
|
||||
# ── roles: add denied_permissions + permission_version ──
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column("denied_permissions", JSONB, nullable=False, server_default="[]"),
|
||||
)
|
||||
op.add_column(
|
||||
"roles",
|
||||
sa.Column("permission_version", sa.Integer, nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
# ── Seed default roles per tenant ──
|
||||
# For each tenant, create admin/editor/viewer role records if they don't exist
|
||||
op.execute("""
|
||||
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at, updated_at)
|
||||
SELECT
|
||||
gen_random_uuid(),
|
||||
t.id,
|
||||
r.role_name,
|
||||
r.permissions::jsonb,
|
||||
'[]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
1,
|
||||
now(),
|
||||
now()
|
||||
FROM tenants t
|
||||
CROSS JOIN (VALUES
|
||||
('admin', '{"*:*": true}'::json),
|
||||
('editor', '{"core:*:read": true, "core:*:write": true, "core:*:create": true}'::json),
|
||||
('viewer', '{"core:*:read": true}'::json)
|
||||
) AS r(role_name, permissions)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM roles ro
|
||||
WHERE ro.tenant_id = t.id AND ro.name = r.role_name
|
||||
)
|
||||
""")
|
||||
|
||||
# ── Migrate existing user.role_id to user_tenants.role_id ──
|
||||
# For each user_tenants row, set role_id from users table if the user has one
|
||||
op.execute("""
|
||||
UPDATE user_tenants ut
|
||||
SET role_id = u.role_id
|
||||
FROM users u
|
||||
WHERE ut.user_id = u.id
|
||||
AND u.role_id IS NOT NULL
|
||||
AND ut.role_id IS NULL
|
||||
""")
|
||||
|
||||
# ── Migrate legacy role strings to role records ──
|
||||
# For users with role='admin'/'editor'/'viewer' but no role_id on user_tenants,
|
||||
# link to the seeded default roles
|
||||
op.execute("""
|
||||
UPDATE user_tenants ut
|
||||
SET role_id = ro.id
|
||||
FROM users u, roles ro
|
||||
WHERE ut.user_id = u.id
|
||||
AND ro.tenant_id = ut.tenant_id
|
||||
AND ro.name = u.role
|
||||
AND ut.role_id IS NULL
|
||||
AND u.role IN ('admin', 'editor', 'viewer')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("roles", "permission_version")
|
||||
op.drop_column("roles", "denied_permissions")
|
||||
op.drop_index("ix_user_tenants_role_id", table_name="user_tenants")
|
||||
op.drop_column("user_tenants", "role_id")
|
||||
op.drop_column("users", "is_system_admin")
|
||||
op.drop_table("user_groups")
|
||||
op.drop_table("groups")
|
||||
Reference in New Issue
Block a user