169 lines
5.5 KiB
Python
169 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
from passlib.context import CryptContext
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
DateTime,
|
|
ForeignKey,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
UniqueConstraint,
|
|
)
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
from sqlalchemy.sql import func
|
|
|
|
from config.database import Base
|
|
|
|
# Configure password hashing strategy. Argon2 provides strong resistance against
|
|
# GPU-based cracking attempts, aligning with the security plan.
|
|
password_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
|
|
|
|
|
class User(Base):
|
|
"""Authenticated platform user with optional elevated privileges."""
|
|
|
|
__tablename__ = "users"
|
|
__table_args__ = (
|
|
UniqueConstraint("email", name="uq_users_email"),
|
|
UniqueConstraint("username", name="uq_users_username"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
username: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
is_active: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=True)
|
|
is_superuser: Mapped[bool] = mapped_column(
|
|
Boolean, nullable=False, default=False)
|
|
last_login_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime(timezone=True), nullable=True
|
|
)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
role_assignments: Mapped[List["UserRole"]] = relationship(
|
|
"UserRole",
|
|
back_populates="user",
|
|
cascade="all, delete-orphan",
|
|
foreign_keys="UserRole.user_id",
|
|
)
|
|
roles: Mapped[List["Role"]] = relationship(
|
|
"Role",
|
|
secondary="user_roles",
|
|
primaryjoin="User.id == UserRole.user_id",
|
|
secondaryjoin="Role.id == UserRole.role_id",
|
|
viewonly=True,
|
|
back_populates="users",
|
|
)
|
|
|
|
def set_password(self, raw_password: str) -> None:
|
|
"""Hash and store a password for the user."""
|
|
|
|
self.password_hash = self.hash_password(raw_password)
|
|
|
|
@staticmethod
|
|
def hash_password(raw_password: str) -> str:
|
|
"""Return the Argon2 hash for a clear-text password."""
|
|
|
|
return password_context.hash(raw_password)
|
|
|
|
def verify_password(self, candidate_password: str) -> bool:
|
|
"""Validate a password against the stored hash."""
|
|
|
|
if not self.password_hash:
|
|
return False
|
|
return password_context.verify(candidate_password, self.password_hash)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
|
return f"User(id={self.id!r}, email={self.email!r})"
|
|
|
|
|
|
class Role(Base):
|
|
"""Role encapsulating a set of permissions."""
|
|
|
|
__tablename__ = "roles"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
|
display_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
updated_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
|
)
|
|
|
|
assignments: Mapped[List["UserRole"]] = relationship(
|
|
"UserRole",
|
|
back_populates="role",
|
|
cascade="all, delete-orphan",
|
|
foreign_keys="UserRole.role_id",
|
|
)
|
|
users: Mapped[List["User"]] = relationship(
|
|
"User",
|
|
secondary="user_roles",
|
|
primaryjoin="Role.id == UserRole.role_id",
|
|
secondaryjoin="User.id == UserRole.user_id",
|
|
viewonly=True,
|
|
back_populates="roles",
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - helpful for debugging
|
|
return f"Role(id={self.id!r}, name={self.name!r})"
|
|
|
|
|
|
class UserRole(Base):
|
|
"""Association between users and roles with assignment metadata."""
|
|
|
|
__tablename__ = "user_roles"
|
|
__table_args__ = (
|
|
UniqueConstraint("user_id", "role_id", name="uq_user_roles_user_role"),
|
|
)
|
|
|
|
user_id: Mapped[int] = mapped_column(
|
|
Integer,
|
|
ForeignKey("users.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
role_id: Mapped[int] = mapped_column(
|
|
Integer,
|
|
ForeignKey("roles.id", ondelete="CASCADE"),
|
|
primary_key=True,
|
|
)
|
|
granted_at: Mapped[datetime] = mapped_column(
|
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
|
)
|
|
granted_by: Mapped[Optional[int]] = mapped_column(
|
|
Integer,
|
|
ForeignKey("users.id", ondelete="SET NULL"),
|
|
nullable=True,
|
|
)
|
|
|
|
user: Mapped["User"] = relationship(
|
|
"User",
|
|
foreign_keys=[user_id],
|
|
back_populates="role_assignments",
|
|
)
|
|
role: Mapped["Role"] = relationship(
|
|
"Role",
|
|
foreign_keys=[role_id],
|
|
back_populates="assignments",
|
|
)
|
|
granted_by_user: Mapped[Optional["User"]] = relationship(
|
|
"User",
|
|
foreign_keys=[granted_by],
|
|
)
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging helper
|
|
return f"UserRole(user_id={self.user_id!r}, role_id={self.role_id!r})"
|