Collaborators logged into GTI Gerenciador open GTI Fiscal without a second password. The panel signs a 60 s JWT (Ed25519; the Hub holds only the public key); the Hub checks signature, iss/aud/exp/nbf, maximum lifetime, nonce against a one-time state cookie set when the flow starts at GET /login (login-CSRF protection) and a single-use jti (sso_used_assertions). Collaborators are provisioned in admin_users with auth_source=GTI_GERENCIADOR and no password; a CHECK constraint makes a passwordless local account or a federated account with a password impossible (migration 0021). Sessions last FEDERATED_SESSION_SECONDS (1 h) and are renewed silently through the panel. The local admin stays as break-glass at /login/local, audited. Logout ends only the Hub session. Every SSO success/rejection (stable reason code), logout and local login is audited with the real client IP, never the token. verify_password now fails closed on a missing hash instead of raising. Visual identity: gti-theme.css is a verbatim copy of the panel's design system (tokens, sidebar, light/dark theme) plus its tema.js, sidebar.js and logo; control-panel.css keeps only Hub components, all colours from the panel tokens. Page structure and features unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
60 lines
3.1 KiB
Python
60 lines
3.1 KiB
Python
"""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)
|