feat: implement user and role models with password hashing, and add tests for user functionality

This commit is contained in:
2025-11-09 21:45:29 +01:00
parent 2d848c2e09
commit 53879a411f
4 changed files with 257 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ from .metadata import (
from .project import MiningOperationType, Project
from .scenario import Scenario, ScenarioStatus
from .simulation_parameter import DistributionType, SimulationParameter
from .user import Role, User, UserRole, password_context
__all__ = [
"FinancialCategory",
@@ -32,4 +33,8 @@ __all__ = [
"STOCHASTIC_VARIABLE_METADATA",
"ResourceDescriptor",
"StochasticVariableDescriptor",
"User",
"Role",
"UserRole",
"password_context",
]

168
models/user.py Normal file
View 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})"