feat: implement user and role models with password hashing, and add tests for user functionality
This commit is contained in:
168
models/user.py
Normal file
168
models/user.py
Normal file
@@ -0,0 +1,168 @@
|
||||
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})"
|
||||
Reference in New Issue
Block a user