"""system governance settings and reusable tenant templates Revision ID: 9d0e1f2a3b4c Revises: 8c9d0e1f2a3b Create Date: 2026-06-15 00:00:00.000000 """ from __future__ import annotations from datetime import datetime, timezone import json from alembic import op import sqlalchemy as sa revision = "9d0e1f2a3b4c" down_revision = "8c9d0e1f2a3b" branch_labels = None depends_on = None def _now() -> datetime: return datetime.now(timezone.utc) def upgrade() -> None: bind = op.get_bind() inspector = sa.inspect(bind) tables = set(inspector.get_table_names()) # Reconcile only the empty create_all shape for the newly introduced tables. for table_name in ("admin_governance_template_assignments", "admin_governance_templates", "core_system_settings"): if table_name in tables: count = bind.execute(sa.text(f"SELECT COUNT(*) FROM {table_name}")).scalar_one() if count: raise RuntimeError(f"Cannot reconcile non-empty create_all table {table_name}") op.drop_table(table_name) op.create_table( "core_system_settings", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="en"), sa.Column("allow_tenant_custom_groups", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("allow_tenant_custom_roles", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("allow_tenant_api_keys", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("settings", sa.JSON(), nullable=False, server_default="{}"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.PrimaryKeyConstraint("id"), ) now = _now() bind.execute(sa.text( """ INSERT INTO core_system_settings (id, default_locale, allow_tenant_custom_groups, allow_tenant_custom_roles, allow_tenant_api_keys, settings, created_at, updated_at) VALUES ('global', 'en', :allow_tenant_custom_groups, :allow_tenant_custom_roles, :allow_tenant_api_keys, '{}', :created_at, :updated_at) """ ), { "allow_tenant_custom_groups": True, "allow_tenant_custom_roles": True, "allow_tenant_api_keys": True, "created_at": now, "updated_at": now, }) op.create_table( "admin_governance_templates", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("kind", sa.String(length=20), nullable=False), sa.Column("slug", sa.String(length=100), nullable=False), sa.Column("name", sa.String(length=255), nullable=False), sa.Column("description", sa.Text(), nullable=True), sa.Column("permissions", sa.JSON(), nullable=False, server_default="[]"), sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("kind", "slug", name="uq_governance_templates_kind_slug"), ) op.create_index(op.f("ix_admin_governance_templates_kind"), "admin_governance_templates", ["kind"]) op.create_table( "admin_governance_template_assignments", sa.Column("id", sa.String(length=36), nullable=False), sa.Column("template_id", sa.String(length=36), nullable=False), sa.Column("tenant_id", sa.String(length=36), nullable=False), sa.Column("mode", sa.String(length=20), nullable=False, server_default="available"), sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), sa.ForeignKeyConstraint(["template_id"], ["admin_governance_templates.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["tenant_id"], ["tenancy_tenants.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("template_id", "tenant_id", name="uq_governance_template_tenant"), ) op.create_index(op.f("ix_admin_governance_template_assignments_template_id"), "admin_governance_template_assignments", ["template_id"]) op.create_index(op.f("ix_admin_governance_template_assignments_tenant_id"), "admin_governance_template_assignments", ["tenant_id"]) with op.batch_alter_table("tenancy_tenants") as batch_op: batch_op.add_column(sa.Column("allow_custom_groups", sa.Boolean(), nullable=True)) batch_op.add_column(sa.Column("allow_custom_roles", sa.Boolean(), nullable=True)) batch_op.add_column(sa.Column("allow_api_keys", sa.Boolean(), nullable=True)) with op.batch_alter_table("access_groups") as batch_op: batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True)) batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false())) batch_op.create_foreign_key( "fk_groups_system_template_id_governance_templates", "admin_governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL", ) op.create_index(op.f("ix_access_groups_system_template_id"), "access_groups", ["system_template_id"]) with op.batch_alter_table("access_roles") as batch_op: batch_op.add_column(sa.Column("system_template_id", sa.String(length=36), nullable=True)) batch_op.add_column(sa.Column("system_required", sa.Boolean(), nullable=False, server_default=sa.false())) batch_op.create_foreign_key( "fk_roles_system_template_id_governance_templates", "admin_governance_templates", ["system_template_id"], ["id"], ondelete="SET NULL", ) op.create_index(op.f("ix_access_roles_system_template_id"), "access_roles", ["system_template_id"]) # Existing system owners use system:* and need no data change. Extend the # read-only built-in auditor role to the newly introduced read scopes. auditor = bind.execute(sa.text( "SELECT id, permissions FROM access_roles WHERE tenant_id IS NULL AND slug = 'system_auditor'" )).mappings().first() if auditor: raw_permissions = auditor["permissions"] or [] permissions = json.loads(raw_permissions) if isinstance(raw_permissions, str) else list(raw_permissions) for scope in ("system:settings:read", "system:governance:read"): if scope not in permissions: permissions.append(scope) roles_table = sa.table( "access_roles", sa.column("id", sa.String), sa.column("permissions", sa.JSON), sa.column("updated_at", sa.DateTime(timezone=True)), ) bind.execute( roles_table.update().where(roles_table.c.id == auditor["id"]).values( permissions=permissions, updated_at=now ) ) def downgrade() -> None: op.drop_index(op.f("ix_access_roles_system_template_id"), table_name="access_roles") with op.batch_alter_table("access_roles") as batch_op: batch_op.drop_constraint("fk_roles_system_template_id_governance_templates", type_="foreignkey") batch_op.drop_column("system_required") batch_op.drop_column("system_template_id") op.drop_index(op.f("ix_access_groups_system_template_id"), table_name="access_groups") with op.batch_alter_table("access_groups") as batch_op: batch_op.drop_constraint("fk_groups_system_template_id_governance_templates", type_="foreignkey") batch_op.drop_column("system_required") batch_op.drop_column("system_template_id") with op.batch_alter_table("tenancy_tenants") as batch_op: batch_op.drop_column("allow_api_keys") batch_op.drop_column("allow_custom_roles") batch_op.drop_column("allow_custom_groups") op.drop_index(op.f("ix_admin_governance_template_assignments_tenant_id"), table_name="admin_governance_template_assignments") op.drop_index(op.f("ix_admin_governance_template_assignments_template_id"), table_name="admin_governance_template_assignments") op.drop_table("admin_governance_template_assignments") op.drop_index(op.f("ix_admin_governance_templates_kind"), table_name="admin_governance_templates") op.drop_table("admin_governance_templates") op.drop_table("core_system_settings")