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:
+30
-2
@@ -1,7 +1,7 @@
|
||||
"""PostgreSQL records owned by the Fiscal Hub."""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Integer, JSON, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
@@ -303,13 +303,41 @@ class AgentEvent(Base):
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
"""Human principal of the control panel.
|
||||
|
||||
LOCAL: break-glass account with a password (create_admin.py).
|
||||
GTI_GERENCIADOR: collaborator provisioned by SSO (central/app/sso.py) — no
|
||||
password at all; identified by the panel's immutable subject.
|
||||
"""
|
||||
__tablename__ = "admin_users"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("external_subject", name="uq_admin_users_external_subject"),
|
||||
CheckConstraint(
|
||||
"(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)",
|
||||
name="ck_admin_users_auth_source",
|
||||
),
|
||||
)
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(128), unique=True, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
auth_source: Mapped[str] = mapped_column(String(16), default="LOCAL", server_default="LOCAL")
|
||||
external_subject: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
external_role: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
class SsoUsedAssertion(Base):
|
||||
"""jti of every accepted SSO assertion, kept past its expiry to reject replays."""
|
||||
__tablename__ = "sso_used_assertions"
|
||||
jti: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
subject: Mapped[str] = mapped_column(String(128))
|
||||
used_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||
|
||||
|
||||
class WebSession(Base):
|
||||
|
||||
Reference in New Issue
Block a user