2026-07-15 21:59:45 +02:00
|
|
|
"""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
|
2026-07-15 22:06:24 +02:00
|
|
|
# Use sa.text() with bindparams to avoid SQLAlchemy interpreting :read/:write as bind params
|
|
|
|
|
for role_name, perms_json in [
|
|
|
|
|
("admin", '{"*:*": true}'),
|
|
|
|
|
("editor", '{"core:*:read": true, "core:*:write": true, "core:*:create": true}'),
|
|
|
|
|
("viewer", '{"core:*:read": true}'),
|
|
|
|
|
]:
|
|
|
|
|
op.execute(
|
|
|
|
|
sa.text("""
|
2026-07-15 22:13:55 +02:00
|
|
|
INSERT INTO roles (id, tenant_id, name, permissions, denied_permissions, field_permissions, permission_version, created_at)
|
2026-07-15 22:06:24 +02:00
|
|
|
SELECT
|
|
|
|
|
gen_random_uuid(),
|
|
|
|
|
t.id,
|
|
|
|
|
:role_name,
|
2026-07-15 22:10:14 +02:00
|
|
|
CAST(:perms AS jsonb),
|
2026-07-15 22:06:24 +02:00
|
|
|
'[]'::jsonb,
|
|
|
|
|
'{}'::jsonb,
|
|
|
|
|
1,
|
|
|
|
|
now()
|
|
|
|
|
FROM tenants t
|
|
|
|
|
WHERE NOT EXISTS (
|
|
|
|
|
SELECT 1 FROM roles ro
|
|
|
|
|
WHERE ro.tenant_id = t.id AND ro.name = :role_name
|
|
|
|
|
)
|
|
|
|
|
""").bindparams(
|
|
|
|
|
sa.bindparam("role_name", value=role_name),
|
|
|
|
|
sa.bindparam("perms", value=perms_json),
|
|
|
|
|
)
|
2026-07-15 21:59:45 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── 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")
|