"""Federated identity for SSO from GTI Gerenciador. admin_users stays the single human principal (web_sessions and audit_events already point at it). Existing rows become auth_source='LOCAL' (the break-glass admin keeps its password). Collaborators provisioned by SSO are auth_source='GTI_GERENCIADOR': no password at all, identified by the panel's immutable subject. The CHECK constraint makes a passwordless LOCAL account, or a federated account with a password, impossible. sso_used_assertions keeps every accepted assertion id (jti) so the same assertion can never be used twice. Revision ID: 0021_sso_federated_identity Revises: 0020_not_null_timestamps """ from alembic import op import sqlalchemy as sa revision = "0021_sso_federated_identity" down_revision = "0020_not_null_timestamps" branch_labels = None depends_on = None AUTH_SOURCE_CHECK = ( "(auth_source = 'LOCAL' AND password_hash IS NOT NULL AND external_subject IS NULL)" " OR (auth_source = 'GTI_GERENCIADOR' AND password_hash IS NULL AND external_subject IS NOT NULL)" ) def upgrade(): op.add_column("admin_users", sa.Column("auth_source", sa.String(16), nullable=False, server_default="LOCAL")) op.add_column("admin_users", sa.Column("external_subject", sa.String(128), nullable=True)) op.add_column("admin_users", sa.Column("display_name", sa.String(160), nullable=True)) op.add_column("admin_users", sa.Column("external_role", sa.String(32), nullable=True)) op.add_column("admin_users", sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False)) op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=True) op.create_unique_constraint("uq_admin_users_external_subject", "admin_users", ["external_subject"]) op.create_check_constraint("ck_admin_users_auth_source", "admin_users", AUTH_SOURCE_CHECK) op.create_table( "sso_used_assertions", sa.Column("jti", sa.String(64), primary_key=True), sa.Column("subject", sa.String(128), nullable=False), sa.Column("used_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), ) op.create_index("ix_sso_used_assertions_expires_at", "sso_used_assertions", ["expires_at"]) def downgrade(): bind = op.get_bind() if bind.execute(sa.text("SELECT count(*) FROM admin_users WHERE auth_source <> 'LOCAL'")).scalar(): raise RuntimeError("0021 downgrade refused: federated users exist (their sessions/audit rows reference them)") op.drop_index("ix_sso_used_assertions_expires_at", table_name="sso_used_assertions") op.drop_table("sso_used_assertions") op.drop_constraint("ck_admin_users_auth_source", "admin_users", type_="check") op.drop_constraint("uq_admin_users_external_subject", "admin_users", type_="unique") op.alter_column("admin_users", "password_hash", existing_type=sa.String(255), nullable=False) for column in ("updated_at", "external_role", "display_name", "external_subject", "auth_source"): op.drop_column("admin_users", column)