feat: SSO from GTI Gerenciador and the Gerenciador visual identity

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>
This commit is contained in:
gti
2026-09-23 12:21:26 -03:00
co-authored by Claude Opus 5.5
parent 91a2dda969
commit 64d3fc56e2
21 changed files with 1290 additions and 122 deletions
@@ -0,0 +1,59 @@
"""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)