Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
- **Single Sign-on (OIDC)**: Anmeldung über Authentik, Keycloak, Entra ID, Google u. a. (Authorization Code mit PKCE). Die Rolle kommt bei jeder Anmeldung aus den Gruppen des Anbieters, ohne zugeordnete Gruppe kommt niemand hinein; SSO-Konten werden nie mit lokalen Konten verknüpft; lokale Anmeldung bleibt als Notzugang. ID-Token werden streng geprüft (Signatur nur asymmetrisch, Aussteller, Zielgruppe, Ablauf, Nonce, einmaliger State), gegen einen nachgebauten Anbieter getestet (inkl. Fälschungsversuche). Neue Tabellen `oidc_settings`, `oidc_login_states`, Spalten `admin_users.auth_source/oidc_subject` (Migration `0013`). Neue Abhängigkeit: PyJWT. - **Behoben:** `opnexus backup --full` und `opnexus restore` (Vollsicherung, in 1.19.0 beschrieben) fehlten im 1.19.0-Bundle und -Updater-Image, weil die Änderung am Skript nicht mit eingecheckt war; sie sind erst ab dieser Version enthalten. - **Aufgabentrennung**: Schalter, mit dem nur Admins ausrollen dürfen, Operatoren aber vorbereiten (Migration `0014`).
62 lines
3.5 KiB
Python
62 lines
3.5 KiB
Python
import uuid
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from sqlalchemy import JSON, BigInteger, Boolean, DateTime, ForeignKey, String, func
|
|
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.database import Base
|
|
|
|
|
|
class AdminUser(Base):
|
|
"""A user account. Two roles, no orgs: "admin" (full read/write, incl.
|
|
managing other users) and "read_only" (every GET works, every write is
|
|
rejected by AuthMiddleware before it reaches a router — see there for
|
|
the exact allowlist). See auth_service.bootstrap_admin for how the
|
|
first account is created."""
|
|
|
|
__tablename__ = "admin_users"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
username: Mapped[str] = mapped_column(String(150), nullable=False, unique=True, index=True)
|
|
# Argon2id PHC string — never the raw password.
|
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
role: Mapped[str] = mapped_column(String(20), nullable=False, default="admin", server_default="admin")
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
last_login_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
|
# Optional TOTP second factor. The secret itself is on disk
|
|
# (services/totp_service), not here; only SHA-256 hashes of the one-time
|
|
# recovery codes and the last accepted time-step (replay guard) are.
|
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
|
|
totp_last_step: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
|
|
totp_recovery_hashes: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
|
|
# "local" (password) or "oidc" (single sign-on: role follows the identity provider's groups at every
|
|
# login, the password hash is an unusable placeholder). oidc_subject is "<issuer>|<sub>".
|
|
auth_source: Mapped[str] = mapped_column(String(10), nullable=False, default="local", server_default="local")
|
|
oidc_subject: Mapped[Optional[str]] = mapped_column(String(600), nullable=True, unique=True, index=True)
|
|
|
|
sessions: Mapped[list["Session"]] = relationship(
|
|
"Session", back_populates="user", cascade="all, delete-orphan"
|
|
)
|
|
|
|
|
|
class Session(Base):
|
|
__tablename__ = "sessions"
|
|
|
|
id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
|
PGUUID(as_uuid=True), ForeignKey("admin_users.id", ondelete="CASCADE"), nullable=False, index=True
|
|
)
|
|
# SHA-256 hex of the random session token. The raw token only ever lives
|
|
# in the client's httpOnly cookie, so a DB leak yields no usable sessions.
|
|
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
user_agent: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
|
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
|
|
|
user: Mapped["AdminUser"] = relationship("AdminUser", back_populates="sessions")
|