"""Auth + RBAC: users, groups, permissions, invitations, refresh tokens.""" from __future__ import annotations import uuid from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, Uuid from sqlalchemy import DateTime as SADateTime from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base from app.db.mixins import SoftDeleteMixin, TimestampMixin, UuidPkMixin if TYPE_CHECKING: from app.models.evidence import EvidenceFile from app.models.notification import Notification class User(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin): __tablename__ = "users" email: Mapped[str] = mapped_column(String(254), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str | None] = mapped_column(String(120), nullable=True) locale: Mapped[str] = mapped_column(String(8), default="fr", nullable=False) is_active: Mapped[bool] = mapped_column(default=True, nullable=False) groups: Mapped[list["Group"]] = relationship( secondary="user_groups", back_populates="users", lazy="selectin", ) refresh_tokens: Mapped[list["RefreshToken"]] = relationship( back_populates="user", cascade="all, delete-orphan", ) notifications: Mapped[list["Notification"]] = relationship( back_populates="user", cascade="all, delete-orphan", ) uploaded_evidence: Mapped[list["EvidenceFile"]] = relationship( back_populates="uploaded_by", ) __table_args__ = ( # Email uniqueness scoped to non-deleted rows so an admin can re-invite # a previously-soft-deleted user. Index( "uq_users_email_active", "email", unique=True, postgresql_where="deleted_at IS NULL", ), Index("ix_users_active", "deleted_at", postgresql_where="deleted_at IS NULL"), ) class Group(Base, UuidPkMixin, TimestampMixin, SoftDeleteMixin): __tablename__ = "groups" name: Mapped[str] = mapped_column(String(80), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) # Built-in groups (admin/redteam/blueteam) are protected from deletion. is_system: Mapped[bool] = mapped_column(default=False, nullable=False) users: Mapped[list[User]] = relationship( secondary="user_groups", back_populates="groups", ) permissions: Mapped[list["Permission"]] = relationship( secondary="group_permissions", back_populates="groups", lazy="selectin", ) __table_args__ = ( Index( "uq_groups_name_active", "name", unique=True, postgresql_where="deleted_at IS NULL", ), Index("ix_groups_active", "deleted_at", postgresql_where="deleted_at IS NULL"), ) class Permission(Base, UuidPkMixin, TimestampMixin): """Atomic permission. Code follows the `.` convention.""" __tablename__ = "permissions" code: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) groups: Mapped[list[Group]] = relationship( secondary="group_permissions", back_populates="permissions", ) class UserGroup(Base): """User ↔ Group join — no soft delete, just attach/detach.""" __tablename__ = "user_groups" user_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True ) group_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True ) class GroupPermission(Base): """Group ↔ Permission join.""" __tablename__ = "group_permissions" group_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True ) permission_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True ) class Invitation(Base, UuidPkMixin, TimestampMixin): __tablename__ = "invitations" # Hash of the URL token, never the token itself. token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) email_hint: Mapped[str | None] = mapped_column(String(254), nullable=True) created_by_user_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False ) expires_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False) consumed_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True) revoked_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True) consumed_by_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True ) pre_assigned_groups: Mapped[list[Group]] = relationship( secondary="invitation_groups", lazy="selectin", ) __table_args__ = (Index("ix_invitations_expires_at", "expires_at"),) class InvitationGroup(Base): """Pre-assigned groups attached to an invitation; applied at acceptance.""" __tablename__ = "invitation_groups" invitation_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("invitations.id", ondelete="CASCADE"), primary_key=True ) group_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True ) class RefreshToken(Base, UuidPkMixin, TimestampMixin): """Long-lived refresh tokens. The hash, never the token, is stored.""" __tablename__ = "refresh_tokens" user_id: Mapped[uuid.UUID] = mapped_column( Uuid(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) jti: Mapped[str] = mapped_column(String(64), nullable=False) token_hash: Mapped[str] = mapped_column(String(128), nullable=False) issued_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(SADateTime(timezone=True), nullable=False) revoked_at: Mapped[datetime | None] = mapped_column(SADateTime(timezone=True), nullable=True) replaced_by_id: Mapped[uuid.UUID | None] = mapped_column( Uuid(as_uuid=True), ForeignKey("refresh_tokens.id", ondelete="SET NULL"), nullable=True ) user: Mapped[User] = relationship(back_populates="refresh_tokens") __table_args__ = ( UniqueConstraint("jti", name="uq_refresh_tokens_jti"), Index("ix_refresh_tokens_user_id_expires_at", "user_id", "expires_at"), )