"""Users, groups and login sessions.""" from __future__ import annotations from datetime import datetime from typing import TYPE_CHECKING, Any from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Index, String, Table, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from lembas.db.base import Base, Timestamps, UUIDPrimaryKey from lembas.db.types import JSONDict if TYPE_CHECKING: # Annotation only; SQLAlchemy resolves the real class from its registry. from lembas.db.models.connection import Model # Roles are a simple ordered ladder rather than a permission matrix. Groups # (below) carry finer-grained permissions once the users/groups UI lands. ROLE_ADMIN = "admin" ROLE_USER = "user" ROLE_PENDING = "pending" # registered but awaiting admin approval user_groups = Table( "user_groups", Base.metadata, Column("user_id", String(32), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), Column("group_id", String(32), ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True), ) class User(UUIDPrimaryKey, Timestamps, Base): __tablename__ = "users" email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) name: Mapped[str] = mapped_column(String(120), nullable=False) password_hash: Mapped[str] = mapped_column(Text, nullable=False) role: Mapped[str] = mapped_column(String(16), default=ROLE_USER, nullable=False) active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Per-user preferences: theme, default model, composer behaviour, etc. settings_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) groups: Mapped[list[Group]] = relationship(secondary=user_groups, back_populates="users") sessions: Mapped[list[Session]] = relationship( back_populates="user", cascade="all, delete-orphan" ) @property def is_admin(self) -> bool: return self.role == ROLE_ADMIN def __repr__(self) -> str: return f"" class Group(UUIDPrimaryKey, Timestamps, Base): """A named set of users. Permissions are enforced once the RBAC pass lands.""" __tablename__ = "groups" name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) description: Mapped[str] = mapped_column(Text, default="") # Only the granted keys need be present. Absent means "no opinion", not # "deny" -- permissions union across a user's groups. See # lembas.security.permissions. permissions_json: Mapped[dict[str, Any]] = mapped_column(JSONDict, default=dict) users: Mapped[list[User]] = relationship(secondary=user_groups, back_populates="groups") models: Mapped[list[Model]] = relationship( "Model", secondary="model_groups", back_populates="groups" ) class Session(UUIDPrimaryKey, Timestamps, Base): """Server-side login session. Sessions live in the database rather than in a signed JWT so that logging out, banning a user, or rotating a device actually revokes access immediately instead of waiting for a token to expire. """ __tablename__ = "sessions" user_id: Mapped[str] = mapped_column( String(32), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) # SHA-256 of the cookie value. The raw token is shown to the browser once # and never stored, so a database leak does not hand over live sessions. token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) user_agent: Mapped[str] = mapped_column(Text, default="") ip_address: Mapped[str] = mapped_column(String(45), default="") user: Mapped[User] = relationship(back_populates="sessions") Index("ix_sessions_user_id", Session.user_id)