From dc06536f2e9bcc6c7f8ee01d58c6076aa745042e Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:43:59 +0200 Subject: [PATCH 1/5] feat(db): add accounts, sessions, persons, access, audit log and settings tables UUIDv7 identifiers, a UTC datetime type that behaves the same on SQLite and PostgreSQL, the first migration, programmatic migrations at start-up and request-scoped sessions. Signed-off-by: Jose David --- backend/alembic/env.py | 1 + backend/alembic/versions/.gitkeep | 0 ...60923_57fafd32a1da_accounts_and_persons.py | 143 ++++++++++++++++++ backend/src/nevus/config.py | 9 ++ backend/src/nevus/db/migrate.py | 24 +++ backend/src/nevus/db/models.py | 125 +++++++++++++++ backend/src/nevus/db/session.py | 20 +++ backend/src/nevus/db/types.py | 35 +++++ backend/src/nevus/ids.py | 15 ++ 9 files changed, 372 insertions(+) delete mode 100644 backend/alembic/versions/.gitkeep create mode 100644 backend/alembic/versions/20260923_57fafd32a1da_accounts_and_persons.py create mode 100644 backend/src/nevus/db/migrate.py create mode 100644 backend/src/nevus/db/models.py create mode 100644 backend/src/nevus/db/session.py create mode 100644 backend/src/nevus/db/types.py create mode 100644 backend/src/nevus/ids.py diff --git a/backend/alembic/env.py b/backend/alembic/env.py index da84014..82405a1 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -9,6 +9,7 @@ from sqlalchemy import engine_from_config, pool from nevus.config import get_settings +from nevus.db import models # noqa: F401 - registers every table on the metadata from nevus.db.base import Base config = context.config diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/backend/alembic/versions/20260923_57fafd32a1da_accounts_and_persons.py b/backend/alembic/versions/20260923_57fafd32a1da_accounts_and_persons.py new file mode 100644 index 0000000..d9ef7f3 --- /dev/null +++ b/backend/alembic/versions/20260923_57fafd32a1da_accounts_and_persons.py @@ -0,0 +1,143 @@ +"""accounts and persons + +Revision ID: 57fafd32a1da +Revises: none +Create Date: 2026-09-23 16:41:36.300192 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision: str = "57fafd32a1da" +down_revision: str | None = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "settings", + sa.Column("key", sa.String(length=64), nullable=False), + sa.Column("value", sa.JSON(), nullable=True), + sa.Column("encrypted", sa.Boolean(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("key", name=op.f("pk_settings")), + ) + op.create_table( + "users", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("email", sa.String(length=254), nullable=True), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("language", sa.String(length=8), nullable=False), + sa.Column("theme", sa.String(length=8), nullable=False), + sa.Column("show_uncertainty", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), + sa.UniqueConstraint("username", name=op.f("uq_users_username")), + ) + op.create_table( + "audit_log", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("at", sa.DateTime(timezone=True), nullable=False), + sa.Column("actor_user_id", sa.Uuid(), nullable=True), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("target_type", sa.String(length=32), nullable=True), + sa.Column("target_id", sa.String(length=36), nullable=True), + sa.Column("ip", sa.String(length=45), nullable=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.ForeignKeyConstraint( + ["actor_user_id"], ["users.id"], name=op.f("fk_audit_log_actor_user_id_users"), ondelete="SET NULL" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_log")), + ) + with op.batch_alter_table("audit_log", schema=None) as batch_op: + batch_op.create_index("ix_audit_log_at", ["at"], unique=False) + + op.create_table( + "auth_sessions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("sudo_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("user_agent", sa.String(length=255), nullable=True), + sa.Column("ip", sa.String(length=45), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], name=op.f("fk_auth_sessions_user_id_users"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_auth_sessions")), + sa.UniqueConstraint("token_hash", name=op.f("uq_auth_sessions_token_hash")), + ) + with op.batch_alter_table("auth_sessions", schema=None) as batch_op: + batch_op.create_index("ix_auth_sessions_user_id", ["user_id"], unique=False) + + op.create_table( + "persons", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("display_name", sa.String(length=120), nullable=False), + sa.Column("birth_year", sa.Integer(), nullable=True), + sa.Column("skin_tone", sa.String(length=16), nullable=True), + sa.Column("owner_user_id", sa.Uuid(), nullable=False), + sa.Column("experimental_analysis", sa.Boolean(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint( + ["owner_user_id"], ["users.id"], name=op.f("fk_persons_owner_user_id_users"), ondelete="RESTRICT" + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_persons")), + ) + with op.batch_alter_table("persons", schema=None) as batch_op: + batch_op.create_index("ix_persons_owner_user_id", ["owner_user_id"], unique=False) + + op.create_table( + "person_access", + sa.Column("person_id", sa.Uuid(), nullable=False), + sa.Column("user_id", sa.Uuid(), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["person_id"], ["persons.id"], name=op.f("fk_person_access_person_id_persons"), ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["user_id"], ["users.id"], name=op.f("fk_person_access_user_id_users"), ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("person_id", "user_id", name=op.f("pk_person_access")), + ) + with op.batch_alter_table("person_access", schema=None) as batch_op: + batch_op.create_index("ix_person_access_user_id", ["user_id"], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("person_access", schema=None) as batch_op: + batch_op.drop_index("ix_person_access_user_id") + + op.drop_table("person_access") + with op.batch_alter_table("persons", schema=None) as batch_op: + batch_op.drop_index("ix_persons_owner_user_id") + + op.drop_table("persons") + with op.batch_alter_table("auth_sessions", schema=None) as batch_op: + batch_op.drop_index("ix_auth_sessions_user_id") + + op.drop_table("auth_sessions") + with op.batch_alter_table("audit_log", schema=None) as batch_op: + batch_op.drop_index("ix_audit_log_at") + + op.drop_table("audit_log") + op.drop_table("users") + op.drop_table("settings") + # ### end Alembic commands ### diff --git a/backend/src/nevus/config.py b/backend/src/nevus/config.py index a9597c1..762179f 100644 --- a/backend/src/nevus/config.py +++ b/backend/src/nevus/config.py @@ -26,6 +26,15 @@ class Settings(BaseSettings): workers: int = Field(default=1, ge=1, le=8, description="Analysis worker processes") bind: str = "0.0.0.0" # noqa: S104 - the container binds all interfaces; the compose file publishes one host port port: int = Field(default=8080, ge=1, le=65535) + auto_migrate: bool = Field(default=True, description="Apply pending database migrations at start-up") + admin_user: str | None = Field(default=None, description="First administrator, created or reset at start-up") + admin_password: str | None = Field(default=None, description="Password for admin_user; read at start-up only") + session_idle_days: int = Field(default=14, ge=1, le=365) + session_max_days: int = Field(default=90, ge=1, le=3650) + sudo_minutes: int = Field(default=5, ge=1, le=60) + login_attempts: int = Field(default=10, ge=3, le=100, description="Failed logins allowed per window") + login_window_minutes: int = Field(default=15, ge=1, le=1440) + trust_proxy_headers: bool = Field(default=False, description="Trust X-Forwarded-Proto/For from a reverse proxy") @field_validator("allowed_hosts", mode="before") @classmethod diff --git a/backend/src/nevus/db/migrate.py b/backend/src/nevus/db/migrate.py new file mode 100644 index 0000000..a03eeb5 --- /dev/null +++ b/backend/src/nevus/db/migrate.py @@ -0,0 +1,24 @@ +"""Run the Alembic migrations programmatically, so a container upgrades its database at start-up.""" + +from __future__ import annotations + +from pathlib import Path + +from alembic import command +from alembic.config import Config + +# backend/alembic in a checkout, /app/alembic in the image (src/nevus/db/migrate.py -> three levels up) +ALEMBIC_DIR = Path(__file__).resolve().parents[3] / "alembic" + + +def alembic_config(database_url: str) -> Config: + config = Config() + config.set_main_option("script_location", str(ALEMBIC_DIR)) + config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%")) + config.set_main_option("prepend_sys_path", str(ALEMBIC_DIR.parent / "src")) + config.set_main_option("path_separator", "os") + return config + + +def upgrade_to_head(database_url: str) -> None: + command.upgrade(alembic_config(database_url), "head") diff --git a/backend/src/nevus/db/models.py b/backend/src/nevus/db/models.py new file mode 100644 index 0000000..3e9d61e --- /dev/null +++ b/backend/src/nevus/db/models.py @@ -0,0 +1,125 @@ +"""Persistent model. Identifiers are UUIDv7; timestamps are UTC; soft deletion uses deleted_at.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Any + +from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, String, Uuid +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from nevus.db.base import Base +from nevus.db.types import UTCDateTime, utcnow +from nevus.ids import uuid7 + +ROLE_ADMIN = "admin" +ROLE_MEMBER = "member" +ACCESS_OWNER = "owner" +ACCESS_MANAGER = "manager" +ACCESS_VIEWER = "viewer" +ACCESS_ROLES = (ACCESS_OWNER, ACCESS_MANAGER, ACCESS_VIEWER) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + email: Mapped[str | None] = mapped_column(String(254)) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(16), nullable=False, default=ROLE_MEMBER) + language: Mapped[str] = mapped_column(String(8), nullable=False, default="en") + theme: Mapped[str] = mapped_column(String(8), nullable=False, default="system") + show_uncertainty: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow, onupdate=utcnow) + last_login_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + disabled_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + + sessions: Mapped[list[AuthSession]] = relationship(back_populates="user", cascade="all, delete-orphan") + + @property + def is_admin(self) -> bool: + return self.role == ROLE_ADMIN + + @property + def is_active(self) -> bool: + return self.disabled_at is None + + +class AuthSession(Base): + __tablename__ = "auth_sessions" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + expires_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False) + sudo_until: Mapped[datetime | None] = mapped_column(UTCDateTime) + user_agent: Mapped[str | None] = mapped_column(String(255)) + ip: Mapped[str | None] = mapped_column(String(45)) + + user: Mapped[User] = relationship(back_populates="sessions") + + __table_args__ = (Index("ix_auth_sessions_user_id", "user_id"),) + + +class Person(Base): + """Somebody whose skin marks are tracked. Not necessarily a user.""" + + __tablename__ = "persons" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + display_name: Mapped[str] = mapped_column(String(120), nullable=False) + birth_year: Mapped[int | None] = mapped_column(Integer) + skin_tone: Mapped[str | None] = mapped_column(String(16)) + owner_user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="RESTRICT"), nullable=False) + experimental_analysis: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow, onupdate=utcnow) + deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime) + + access: Mapped[list[PersonAccess]] = relationship(back_populates="person", cascade="all, delete-orphan") + + __table_args__ = (Index("ix_persons_owner_user_id", "owner_user_id"),) + + +class PersonAccess(Base): + __tablename__ = "person_access" + + person_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("persons.id", ondelete="CASCADE"), primary_key=True) + user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True) + role: Mapped[str] = mapped_column(String(16), nullable=False) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + + person: Mapped[Person] = relationship(back_populates="access") + + __table_args__ = (Index("ix_person_access_user_id", "user_id"),) + + +class AuditLog(Base): + """Who did what to which record. Identifiers only, never content.""" + + __tablename__ = "audit_log" + + id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid7) + at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow) + actor_user_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, ForeignKey("users.id", ondelete="SET NULL")) + action: Mapped[str] = mapped_column(String(64), nullable=False) + target_type: Mapped[str | None] = mapped_column(String(32)) + target_id: Mapped[str | None] = mapped_column(String(36)) + ip: Mapped[str | None] = mapped_column(String(45)) + details: Mapped[dict[str, Any] | None] = mapped_column(JSON) + + __table_args__ = (Index("ix_audit_log_at", "at"),) + + +class Setting(Base): + __tablename__ = "settings" + + key: Mapped[str] = mapped_column(String(64), primary_key=True) + value: Mapped[dict[str, Any] | list[Any] | str | int | bool | None] = mapped_column(JSON) + encrypted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime, nullable=False, default=utcnow, onupdate=utcnow) diff --git a/backend/src/nevus/db/session.py b/backend/src/nevus/db/session.py new file mode 100644 index 0000000..c9cd3f1 --- /dev/null +++ b/backend/src/nevus/db/session.py @@ -0,0 +1,20 @@ +"""Request-scoped database sessions.""" + +from __future__ import annotations + +from collections.abc import Iterator + +from fastapi import Request +from sqlalchemy.orm import Session + + +def get_db(request: Request) -> Iterator[Session]: + session: Session = request.app.state.session_factory() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/backend/src/nevus/db/types.py b/backend/src/nevus/db/types.py new file mode 100644 index 0000000..3328f53 --- /dev/null +++ b/backend/src/nevus/db/types.py @@ -0,0 +1,35 @@ +"""Column types that behave the same on SQLite and PostgreSQL.""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import DateTime +from sqlalchemy.engine import Dialect +from sqlalchemy.types import TypeDecorator + + +class UTCDateTime(TypeDecorator[datetime]): + """Store timezone-aware UTC datetimes; SQLite drops the offset, so it is restored on the way out.""" + + impl = DateTime(timezone=True) + cache_ok = True + + def process_bind_param(self, value: datetime | None, dialect: Dialect) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + raise ValueError("naive datetimes are not accepted; use timezone-aware UTC values") + return value.astimezone(UTC) + + def process_result_value(self, value: Any, dialect: Dialect) -> datetime | None: + if value is None: + return None + if isinstance(value, datetime): + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + return datetime.fromisoformat(str(value)).replace(tzinfo=UTC) + + +def utcnow() -> datetime: + return datetime.now(UTC) diff --git a/backend/src/nevus/ids.py b/backend/src/nevus/ids.py new file mode 100644 index 0000000..91617e8 --- /dev/null +++ b/backend/src/nevus/ids.py @@ -0,0 +1,15 @@ +"""UUIDv7 identifiers (RFC 9562): time-ordered, so indexes stay local and clients can mint them offline.""" + +from __future__ import annotations + +import os +import time +import uuid + + +def uuid7() -> uuid.UUID: + milliseconds = time.time_ns() // 1_000_000 + rand_a = int.from_bytes(os.urandom(2), "big") & 0x0FFF + rand_b = int.from_bytes(os.urandom(8), "big") & 0x3FFF_FFFF_FFFF_FFFF + value = (milliseconds << 80) | (0x7 << 76) | (rand_a << 64) | (0b10 << 62) | rand_b + return uuid.UUID(int=value) From e34e8f54de623ffa1f98865830a21e5d7247224d Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:43:59 +0200 Subject: [PATCH 2/5] feat(auth): local accounts with argon2id, cookie sessions, sudo mode, rate limiting and CSRF protection Passwords hashed with argon2id; sessions stored as hashes with idle and absolute lifetimes; a five-minute sudo mode for destructive actions; an in-memory login limiter; cross-site request forgery refused through fetch metadata or origin checks; an audit log with identifiers only. Signed-off-by: Jose David --- backend/src/nevus/auth/__init__.py | 1 + backend/src/nevus/auth/dependencies.py | 70 +++++++++++++ backend/src/nevus/auth/passwords.py | 25 +++++ backend/src/nevus/auth/ratelimit.py | 33 ++++++ backend/src/nevus/auth/service.py | 138 +++++++++++++++++++++++++ backend/src/nevus/web/csrf.py | 34 ++++++ 6 files changed, 301 insertions(+) create mode 100644 backend/src/nevus/auth/__init__.py create mode 100644 backend/src/nevus/auth/dependencies.py create mode 100644 backend/src/nevus/auth/passwords.py create mode 100644 backend/src/nevus/auth/ratelimit.py create mode 100644 backend/src/nevus/auth/service.py create mode 100644 backend/src/nevus/web/csrf.py diff --git a/backend/src/nevus/auth/__init__.py b/backend/src/nevus/auth/__init__.py new file mode 100644 index 0000000..080e1a8 --- /dev/null +++ b/backend/src/nevus/auth/__init__.py @@ -0,0 +1 @@ +"""Accounts, sessions and authorisation.""" diff --git a/backend/src/nevus/auth/dependencies.py b/backend/src/nevus/auth/dependencies.py new file mode 100644 index 0000000..87b639a --- /dev/null +++ b/backend/src/nevus/auth/dependencies.py @@ -0,0 +1,70 @@ +"""FastAPI dependencies: the current user, administrator checks and sudo mode.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from nevus.auth.service import SESSION_COOKIE, has_sudo, resolve_session +from nevus.config import Settings +from nevus.db.models import AuthSession, User +from nevus.db.session import get_db + + +def get_settings_dep(request: Request) -> Settings: + settings: Settings = request.app.state.settings + return settings + + +def client_ip(request: Request, settings: Settings) -> str | None: + if settings.trust_proxy_headers: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip()[:45] + return request.client.host[:45] if request.client else None + + +def request_is_secure(request: Request, settings: Settings) -> bool: + if settings.trust_proxy_headers and request.headers.get("x-forwarded-proto", "").lower() == "https": + return True + return request.url.scheme == "https" + + +def current_session( + request: Request, + db: Annotated[Session, Depends(get_db)], + settings: Annotated[Settings, Depends(get_settings_dep)], +) -> AuthSession: + token = request.cookies.get(SESSION_COOKIE) + session = resolve_session(db, token, settings) if token else None + if session is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Sign in to continue.") + return session + + +def current_user(session: Annotated[AuthSession, Depends(current_session)]) -> User: + return session.user + + +def require_admin(user: Annotated[User, Depends(current_user)]) -> User: + if not user.is_admin: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Administrator role required.") + return user + + +def require_sudo(session: Annotated[AuthSession, Depends(current_session)]) -> AuthSession: + if not has_sudo(session): + raise HTTPException( + status.HTTP_403_FORBIDDEN, "Confirm your password to continue.", headers={"X-Sudo-Required": "1"} + ) + return session + + +CurrentUser = Annotated[User, Depends(current_user)] +CurrentSession = Annotated[AuthSession, Depends(current_session)] +AdminUser = Annotated[User, Depends(require_admin)] +SudoSession = Annotated[AuthSession, Depends(require_sudo)] +DbSession = Annotated[Session, Depends(get_db)] +AppSettings = Annotated[Settings, Depends(get_settings_dep)] diff --git a/backend/src/nevus/auth/passwords.py b/backend/src/nevus/auth/passwords.py new file mode 100644 index 0000000..4153079 --- /dev/null +++ b/backend/src/nevus/auth/passwords.py @@ -0,0 +1,25 @@ +"""Password hashing with argon2id, tuned to roughly 150 ms on a low-power x86 core.""" + +from __future__ import annotations + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError + +_hasher = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=2, hash_len=32, salt_len=16) + +MIN_PASSWORD_LENGTH = 10 + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password_hash: str, password: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except (VerifyMismatchError, InvalidHashError): + return False + + +def needs_rehash(password_hash: str) -> bool: + return _hasher.check_needs_rehash(password_hash) diff --git a/backend/src/nevus/auth/ratelimit.py b/backend/src/nevus/auth/ratelimit.py new file mode 100644 index 0000000..eced52d --- /dev/null +++ b/backend/src/nevus/auth/ratelimit.py @@ -0,0 +1,33 @@ +"""In-memory sliding-window limiter for login attempts. One process serves the site, so memory is enough.""" + +from __future__ import annotations + +import threading +import time +from collections import defaultdict, deque + + +class LoginRateLimiter: + def __init__(self, attempts: int, window_seconds: int) -> None: + self.attempts = attempts + self.window = window_seconds + self._events: dict[str, deque[float]] = defaultdict(deque) + self._lock = threading.Lock() + + def _prune(self, key: str, now: float) -> deque[float]: + events = self._events[key] + while events and events[0] <= now - self.window: + events.popleft() + return events + + def allow(self, key: str) -> bool: + with self._lock: + return len(self._prune(key, time.monotonic())) < self.attempts + + def record_failure(self, key: str) -> None: + with self._lock: + self._prune(key, time.monotonic()).append(time.monotonic()) + + def reset(self, key: str) -> None: + with self._lock: + self._events.pop(key, None) diff --git a/backend/src/nevus/auth/service.py b/backend/src/nevus/auth/service.py new file mode 100644 index 0000000..1dc187d --- /dev/null +++ b/backend/src/nevus/auth/service.py @@ -0,0 +1,138 @@ +"""Account and session operations. Routers call these; tests exercise them through the API.""" + +from __future__ import annotations + +import hashlib +import secrets +import uuid +from datetime import timedelta +from typing import Any + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from nevus.auth.passwords import hash_password, needs_rehash, verify_password +from nevus.config import Settings +from nevus.db.models import ROLE_ADMIN, AuditLog, AuthSession, User +from nevus.db.types import utcnow + +SESSION_COOKIE = "nevus_session" + + +def normalise_username(username: str) -> str: + return username.strip().lower() + + +def count_users(db: Session) -> int: + return int(db.scalar(select(func.count()).select_from(User)) or 0) + + +def get_user_by_username(db: Session, username: str) -> User | None: + return db.scalar(select(User).where(User.username == normalise_username(username))) + + +def create_user(db: Session, username: str, password: str, role: str, email: str | None = None) -> User: + user = User(username=normalise_username(username), password_hash=hash_password(password), role=role, email=email) + db.add(user) + db.flush() + return user + + +def authenticate(db: Session, username: str, password: str) -> User | None: + user = get_user_by_username(db, username) + if user is None or not user.is_active or not verify_password(user.password_hash, password): + return None + if needs_rehash(user.password_hash): + user.password_hash = hash_password(password) + user.last_login_at = utcnow() + return user + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + + +def create_session(db: Session, user: User, settings: Settings, user_agent: str | None, ip: str | None) -> str: + """Create a session and return the raw token for the cookie; only its hash is stored.""" + token = secrets.token_urlsafe(32) + now = utcnow() + session = AuthSession( + token_hash=_hash_token(token), + user_id=user.id, + created_at=now, + last_seen_at=now, + expires_at=now + timedelta(days=settings.session_max_days), + user_agent=(user_agent or "")[:255] or None, + ip=ip, + ) + db.add(session) + db.flush() + return token + + +def resolve_session(db: Session, token: str, settings: Settings) -> AuthSession | None: + """Return the live session for a cookie token, touching last_seen_at; expired or idle sessions are deleted.""" + session = db.scalar(select(AuthSession).where(AuthSession.token_hash == _hash_token(token))) + if session is None: + return None + now = utcnow() + idle_limit = session.last_seen_at + timedelta(days=settings.session_idle_days) + if now >= session.expires_at or now >= idle_limit or not session.user.is_active: + db.delete(session) + return None + if now - session.last_seen_at > timedelta(minutes=1): + session.last_seen_at = now + return session + + +def revoke_session(db: Session, session: AuthSession) -> None: + db.delete(session) + + +def revoke_all_sessions(db: Session, user: User) -> None: + for session in list(user.sessions): + db.delete(session) + + +def grant_sudo(session: AuthSession, settings: Settings) -> None: + session.sudo_until = utcnow() + timedelta(minutes=settings.sudo_minutes) + + +def has_sudo(session: AuthSession) -> bool: + return session.sudo_until is not None and session.sudo_until > utcnow() + + +def audit( + db: Session, + action: str, + actor: User | None, + target_type: str | None = None, + target_id: uuid.UUID | str | None = None, + ip: str | None = None, + details: dict[str, Any] | None = None, +) -> None: + db.add( + AuditLog( + actor_user_id=actor.id if actor else None, + action=action, + target_type=target_type, + target_id=str(target_id) if target_id is not None else None, + ip=ip, + details=details, + ) + ) + + +def bootstrap_admin(db: Session, settings: Settings) -> None: + """Create or reset the administrator named in the environment. Read at start-up only.""" + if not settings.admin_user or not settings.admin_password: + return + user = get_user_by_username(db, settings.admin_user) + if user is None: + user = create_user(db, settings.admin_user, settings.admin_password, ROLE_ADMIN) + audit(db, "user.bootstrap", None, "user", user.id) + else: + user.password_hash = hash_password(settings.admin_password) + user.role = ROLE_ADMIN + user.disabled_at = None + audit(db, "user.bootstrap_reset", None, "user", user.id) diff --git a/backend/src/nevus/web/csrf.py b/backend/src/nevus/web/csrf.py new file mode 100644 index 0000000..6e78c07 --- /dev/null +++ b/backend/src/nevus/web/csrf.py @@ -0,0 +1,34 @@ +"""Cross-site request forgery protection for the API. + +Sessions live in a SameSite=Lax cookie, so cross-site POSTs already lose the cookie in modern browsers. This +middleware adds the belt to those braces: unsafe requests to /api must come from this origin, proven by the +browser's Sec-Fetch-Site header or, for browsers without it, by an Origin header that matches the Host. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +UNSAFE = {"POST", "PUT", "PATCH", "DELETE"} + + +def request_is_same_site(request: Request) -> bool: + fetch_site = request.headers.get("sec-fetch-site") + if fetch_site is not None: + return fetch_site in {"same-origin", "none"} + origin = request.headers.get("origin") + if origin is None: + return True # not a browser (curl, tests); the session cookie is still required + host = request.headers.get("host", "") + return origin.split("://", 1)[-1].lower() == host.lower() + + +class CsrfMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: + if request.method in UNSAFE and request.url.path.startswith("/api/") and not request_is_same_site(request): + return JSONResponse({"detail": "Cross-site request refused."}, status_code=403) + return await call_next(request) From d20e8f9c5223ffb3b86da07ff73e8bb250c0a23e Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:43:59 +0200 Subject: [PATCH 3/5] feat(api): claim flow, sign-in, account administration and persons with owner, manager and viewer access The first person claims the instance and becomes administrator; administrators create, disable and reset accounts; each user keeps language, theme and uncertainty preferences; persons are scoped to the users that may see them, shared by their owner, and soft-deleted under sudo mode. Signed-off-by: Jose David --- backend/src/nevus/api/auth.py | 116 ++++++++++++++++++++++ backend/src/nevus/api/persons.py | 159 +++++++++++++++++++++++++++++++ backend/src/nevus/api/schemas.py | 121 +++++++++++++++++++++++ backend/src/nevus/api/users.py | 72 ++++++++++++++ backend/src/nevus/app.py | 20 +++- 5 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 backend/src/nevus/api/auth.py create mode 100644 backend/src/nevus/api/persons.py create mode 100644 backend/src/nevus/api/schemas.py create mode 100644 backend/src/nevus/api/users.py diff --git a/backend/src/nevus/api/auth.py b/backend/src/nevus/api/auth.py new file mode 100644 index 0000000..bb90c9a --- /dev/null +++ b/backend/src/nevus/api/auth.py @@ -0,0 +1,116 @@ +"""Claiming the instance, signing in and out, the current session, sudo mode and the own account.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request, Response, status + +from nevus import __version__ +from nevus.api.schemas import Credentials, InstanceStatus, PasswordChange, SessionOut, SudoIn, UserOut, UserUpdateMe +from nevus.auth import service +from nevus.auth.dependencies import AppSettings, CurrentSession, DbSession, client_ip, request_is_secure +from nevus.auth.passwords import hash_password +from nevus.auth.ratelimit import LoginRateLimiter +from nevus.auth.service import SESSION_COOKIE +from nevus.db.models import ROLE_ADMIN + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _limiter(request: Request) -> LoginRateLimiter: + limiter: LoginRateLimiter = request.app.state.login_limiter + return limiter + + +def _set_cookie(response: Response, token: str, request: Request, settings: AppSettings) -> None: + response.set_cookie( + SESSION_COOKIE, + token, + max_age=settings.session_max_days * 86400, + httponly=True, + samesite="lax", + secure=request_is_secure(request, settings), + path="/", + ) + + +@router.get("/instance", response_model=InstanceStatus) +def instance_status(db: DbSession) -> InstanceStatus: + return InstanceStatus(claimed=service.count_users(db) > 0, version=__version__) + + +@router.post("/claim", response_model=SessionOut, status_code=status.HTTP_201_CREATED) +def claim(body: Credentials, request: Request, response: Response, db: DbSession, settings: AppSettings) -> SessionOut: + """The first person in claims the instance and becomes its administrator. Refused once anybody exists.""" + if service.count_users(db) > 0: + raise HTTPException(status.HTTP_409_CONFLICT, "This instance has already been claimed.") + user = service.create_user(db, body.username, body.password, ROLE_ADMIN) + ip = client_ip(request, settings) + service.audit(db, "instance.claim", user, "user", user.id, ip) + token = service.create_session(db, user, settings, request.headers.get("user-agent"), ip) + _set_cookie(response, token, request, settings) + return SessionOut(user=UserOut.model_validate(user), sudo_until=None) + + +@router.post("/login", response_model=SessionOut) +def login(body: Credentials, request: Request, response: Response, db: DbSession, settings: AppSettings) -> SessionOut: + ip = client_ip(request, settings) + key = f"{ip}|{service.normalise_username(body.username)}" + limiter = _limiter(request) + if not limiter.allow(key): + raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, "Too many attempts. Try again later.") + user = service.authenticate(db, body.username, body.password) + if user is None: + limiter.record_failure(key) + service.audit(db, "login.failed", None, "user", service.normalise_username(body.username), ip) + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong username or password.") + limiter.reset(key) + token = service.create_session(db, user, settings, request.headers.get("user-agent"), ip) + service.audit(db, "login", user, "user", user.id, ip) + _set_cookie(response, token, request, settings) + return SessionOut(user=UserOut.model_validate(user), sudo_until=None) + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +def logout(response: Response, session: CurrentSession, db: DbSession) -> Response: + service.revoke_session(db, session) + response.delete_cookie(SESSION_COOKIE, path="/") + return Response(status_code=status.HTTP_204_NO_CONTENT, headers=response.headers) + + +@router.get("/session", response_model=SessionOut) +def session_info(session: CurrentSession) -> SessionOut: + return SessionOut(user=UserOut.model_validate(session.user), sudo_until=session.sudo_until) + + +@router.post("/sudo", response_model=SessionOut) +def sudo(body: SudoIn, request: Request, session: CurrentSession, db: DbSession, settings: AppSettings) -> SessionOut: + """Re-authenticate for a few minutes before destructive actions.""" + if service.authenticate(db, session.user.username, body.password) is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong password.") + service.grant_sudo(session, settings) + service.audit(db, "sudo", session.user, "user", session.user.id, client_ip(request, settings)) + return SessionOut(user=UserOut.model_validate(session.user), sudo_until=session.sudo_until) + + +@router.patch("/me", response_model=UserOut) +def update_me(body: UserUpdateMe, session: CurrentSession, db: DbSession) -> UserOut: + user = session.user + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(user, field, value) + db.flush() + return UserOut.model_validate(user) + + +@router.post("/me/password", status_code=status.HTTP_204_NO_CONTENT) +def change_password( + body: PasswordChange, request: Request, session: CurrentSession, db: DbSession, settings: AppSettings +) -> Response: + user = session.user + if service.authenticate(db, user.username, body.current_password) is None: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong current password.") + user.password_hash = hash_password(body.new_password) + for other in list(user.sessions): + if other.id != session.id: + db.delete(other) + service.audit(db, "password.change", user, "user", user.id, client_ip(request, settings)) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/nevus/api/persons.py b/backend/src/nevus/api/persons.py new file mode 100644 index 0000000..39c6933 --- /dev/null +++ b/backend/src/nevus/api/persons.py @@ -0,0 +1,159 @@ +"""Persons whose skin marks are tracked, and who may see or manage them.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, HTTPException, Request, Response, status +from sqlalchemy import select +from sqlalchemy.orm import Session + +from nevus.api.schemas import AccessIn, AccessOut, PersonIn, PersonOut, PersonUpdate +from nevus.auth import service +from nevus.auth.dependencies import AppSettings, CurrentUser, DbSession, SudoSession, client_ip +from nevus.db.models import ACCESS_MANAGER, ACCESS_OWNER, ACCESS_VIEWER, Person, PersonAccess, User +from nevus.db.types import utcnow + +router = APIRouter(prefix="/api/persons", tags=["persons"]) + + +def _access_for(db: Session, person_id: uuid.UUID, user: User) -> PersonAccess | None: + return db.get(PersonAccess, (person_id, user.id)) + + +def _person_out(person: Person, role: str) -> PersonOut: + return PersonOut( + id=person.id, + display_name=person.display_name, + birth_year=person.birth_year, + skin_tone=person.skin_tone, + owner_user_id=person.owner_user_id, + experimental_analysis=person.experimental_analysis, + created_at=person.created_at, + updated_at=person.updated_at, + my_role=role, + ) + + +def _load(db: Session, person_id: uuid.UUID, user: User, *roles: str) -> tuple[Person, PersonAccess]: + """Return the person and the caller's access row, or 404 when the person is invisible to the caller.""" + person = db.get(Person, person_id) + access = _access_for(db, person_id, user) if person else None + if person is None or person.deleted_at is not None or access is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such person.") + if roles and access.role not in roles: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Your access to this person does not allow that.") + return person, access + + +@router.get("", response_model=list[PersonOut]) +def list_persons(user: CurrentUser, db: DbSession) -> list[PersonOut]: + rows = db.execute( + select(Person, PersonAccess.role) + .join(PersonAccess, PersonAccess.person_id == Person.id) + .where(PersonAccess.user_id == user.id, Person.deleted_at.is_(None)) + .order_by(Person.display_name) + ) + return [_person_out(person, role) for person, role in rows] + + +@router.post("", response_model=PersonOut, status_code=status.HTTP_201_CREATED) +def create_person( + body: PersonIn, request: Request, user: CurrentUser, db: DbSession, settings: AppSettings +) -> PersonOut: + person = Person( + display_name=body.display_name, birth_year=body.birth_year, skin_tone=body.skin_tone, owner_user_id=user.id + ) + db.add(person) + db.flush() + db.add(PersonAccess(person_id=person.id, user_id=user.id, role=ACCESS_OWNER)) + db.flush() + service.audit(db, "person.create", user, "person", person.id, client_ip(request, settings)) + return _person_out(person, ACCESS_OWNER) + + +@router.get("/{person_id}", response_model=PersonOut) +def get_person(person_id: uuid.UUID, user: CurrentUser, db: DbSession) -> PersonOut: + person, access = _load(db, person_id, user) + return _person_out(person, access.role) + + +@router.patch("/{person_id}", response_model=PersonOut) +def update_person( + person_id: uuid.UUID, body: PersonUpdate, request: Request, user: CurrentUser, db: DbSession, settings: AppSettings +) -> PersonOut: + person, access = _load(db, person_id, user, ACCESS_OWNER, ACCESS_MANAGER) + changes = body.model_dump(exclude_unset=True) + if "experimental_analysis" in changes and access.role != ACCESS_OWNER: + raise HTTPException(status.HTTP_403_FORBIDDEN, "Only the owner can change the experimental analysis setting.") + for field, value in changes.items(): + setattr(person, field, value) + db.flush() + service.audit( + db, "person.update", user, "person", person.id, client_ip(request, settings), {"fields": sorted(changes)} + ) + return _person_out(person, access.role) + + +@router.delete("/{person_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_person( + person_id: uuid.UUID, request: Request, session: SudoSession, db: DbSession, settings: AppSettings +) -> Response: + """Soft delete (30-day trash); the purge arrives with the data-management features. Needs sudo mode.""" + user = session.user + person, _ = _load(db, person_id, user, ACCESS_OWNER) + person.deleted_at = utcnow() + service.audit(db, "person.delete", user, "person", person.id, client_ip(request, settings)) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/{person_id}/access", response_model=list[AccessOut]) +def list_access(person_id: uuid.UUID, user: CurrentUser, db: DbSession) -> list[AccessOut]: + _load(db, person_id, user) + rows = db.execute( + select(PersonAccess.user_id, User.username, PersonAccess.role) + .join(User, User.id == PersonAccess.user_id) + .where(PersonAccess.person_id == person_id) + .order_by(User.username) + ) + return [AccessOut(user_id=uid, username=username, role=role) for uid, username, role in rows] + + +@router.put("/{person_id}/access", response_model=AccessOut) +def grant_access( + person_id: uuid.UUID, body: AccessIn, request: Request, user: CurrentUser, db: DbSession, settings: AppSettings +) -> AccessOut: + _load(db, person_id, user, ACCESS_OWNER) + grantee = service.get_user_by_username(db, body.username) + if grantee is None or not grantee.is_active: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such user.") + if grantee.id == user.id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "You already own this person.") + existing = _access_for(db, person_id, grantee) + if existing is None: + db.add(PersonAccess(person_id=person_id, user_id=grantee.id, role=body.role)) + else: + existing.role = body.role + db.flush() + service.audit( + db, "person.access_grant", user, "person", person_id, client_ip(request, settings), {"role": body.role} + ) + return AccessOut(user_id=grantee.id, username=grantee.username, role=body.role) + + +@router.delete("/{person_id}/access/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +def revoke_access( + person_id: uuid.UUID, user_id: uuid.UUID, request: Request, user: CurrentUser, db: DbSession, settings: AppSettings +) -> Response: + person, _ = _load(db, person_id, user, ACCESS_OWNER) + if user_id == person.owner_user_id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "The owner's access cannot be revoked.") + access = db.get(PersonAccess, (person_id, user_id)) + if access is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such access.") + db.delete(access) + service.audit(db, "person.access_revoke", user, "person", person_id, client_ip(request, settings)) + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +__all__ = ["ACCESS_VIEWER", "router"] diff --git a/backend/src/nevus/api/schemas.py b/backend/src/nevus/api/schemas.py new file mode 100644 index 0000000..2108b23 --- /dev/null +++ b/backend/src/nevus/api/schemas.py @@ -0,0 +1,121 @@ +"""Request and response bodies.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from nevus.auth.passwords import MIN_PASSWORD_LENGTH + +Username = Field(min_length=2, max_length=64, pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]*$") +Password = Field(min_length=MIN_PASSWORD_LENGTH, max_length=1024) +Language = Literal["en", "es"] +Theme = Literal["system", "light", "dark"] +AccessRole = Literal["owner", "manager", "viewer"] + + +class Credentials(BaseModel): + username: str = Username + password: str = Password + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + username: str + email: str | None + role: Literal["admin", "member"] + language: str + theme: str + show_uncertainty: bool + created_at: datetime + last_login_at: datetime | None + disabled_at: datetime | None + + +class SessionOut(BaseModel): + user: UserOut + sudo_until: datetime | None + instance_claimed: bool = True + + +class InstanceStatus(BaseModel): + claimed: bool + version: str + + +class UserCreate(BaseModel): + username: str = Username + password: str = Password + role: Literal["admin", "member"] = "member" + email: str | None = Field(default=None, max_length=254) + + +class UserUpdateMe(BaseModel): + language: Language | None = None + theme: Theme | None = None + show_uncertainty: bool | None = None + email: str | None = Field(default=None, max_length=254) + + +class PasswordChange(BaseModel): + current_password: str = Field(min_length=1, max_length=1024) + new_password: str = Password + + +class PasswordReset(BaseModel): + new_password: str = Password + + +class SudoIn(BaseModel): + password: str = Field(min_length=1, max_length=1024) + + +class PersonIn(BaseModel): + display_name: str = Field(min_length=1, max_length=120) + birth_year: int | None = Field(default=None, ge=1900, le=2100) + skin_tone: str | None = Field(default=None, max_length=16) + + @field_validator("display_name") + @classmethod + def _strip(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("display_name must not be blank") + return value + + +class PersonUpdate(BaseModel): + display_name: str | None = Field(default=None, min_length=1, max_length=120) + birth_year: int | None = Field(default=None, ge=1900, le=2100) + skin_tone: str | None = Field(default=None, max_length=16) + experimental_analysis: bool | None = None + + +class PersonOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + display_name: str + birth_year: int | None + skin_tone: str | None + owner_user_id: uuid.UUID + experimental_analysis: bool + created_at: datetime + updated_at: datetime + my_role: AccessRole + + +class AccessOut(BaseModel): + user_id: uuid.UUID + username: str + role: AccessRole + + +class AccessIn(BaseModel): + username: str = Username + role: Literal["manager", "viewer"] diff --git a/backend/src/nevus/api/users.py b/backend/src/nevus/api/users.py new file mode 100644 index 0000000..980e744 --- /dev/null +++ b/backend/src/nevus/api/users.py @@ -0,0 +1,72 @@ +"""Administration of accounts. Registration is closed: administrators create everyone else.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, HTTPException, Request, Response, status +from sqlalchemy import select + +from nevus.api.schemas import PasswordReset, UserCreate, UserOut +from nevus.auth import service +from nevus.auth.dependencies import AdminUser, AppSettings, DbSession, client_ip +from nevus.auth.passwords import hash_password +from nevus.db.models import User +from nevus.db.types import utcnow + +router = APIRouter(prefix="/api/users", tags=["users"]) + + +def _get_user(db: DbSession, user_id: uuid.UUID) -> User: + user = db.get(User, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "No such user.") + return user + + +@router.get("", response_model=list[UserOut]) +def list_users(_: AdminUser, db: DbSession) -> list[UserOut]: + return [UserOut.model_validate(u) for u in db.scalars(select(User).order_by(User.username))] + + +@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED) +def create_user(body: UserCreate, request: Request, admin: AdminUser, db: DbSession, settings: AppSettings) -> UserOut: + if service.get_user_by_username(db, body.username) is not None: + raise HTTPException(status.HTTP_409_CONFLICT, "That username is taken.") + user = service.create_user(db, body.username, body.password, body.role, body.email) + service.audit(db, "user.create", admin, "user", user.id, client_ip(request, settings), {"role": body.role}) + return UserOut.model_validate(user) + + +@router.post("/{user_id}/disable", response_model=UserOut) +def disable_user( + user_id: uuid.UUID, request: Request, admin: AdminUser, db: DbSession, settings: AppSettings +) -> UserOut: + user = _get_user(db, user_id) + if user.id == admin.id: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "You cannot disable your own account.") + user.disabled_at = utcnow() + service.revoke_all_sessions(db, user) + service.audit(db, "user.disable", admin, "user", user.id, client_ip(request, settings)) + return UserOut.model_validate(user) + + +@router.post("/{user_id}/enable", response_model=UserOut) +def enable_user( + user_id: uuid.UUID, request: Request, admin: AdminUser, db: DbSession, settings: AppSettings +) -> UserOut: + user = _get_user(db, user_id) + user.disabled_at = None + service.audit(db, "user.enable", admin, "user", user.id, client_ip(request, settings)) + return UserOut.model_validate(user) + + +@router.post("/{user_id}/password", status_code=status.HTTP_204_NO_CONTENT) +def reset_password( + user_id: uuid.UUID, body: PasswordReset, request: Request, admin: AdminUser, db: DbSession, settings: AppSettings +) -> Response: + user = _get_user(db, user_id) + user.password_hash = hash_password(body.new_password) + service.revoke_all_sessions(db, user) + service.audit(db, "user.password_reset", admin, "user", user.id, client_ip(request, settings)) + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/src/nevus/app.py b/backend/src/nevus/app.py index 07acb87..83e5181 100644 --- a/backend/src/nevus/app.py +++ b/backend/src/nevus/app.py @@ -9,10 +9,17 @@ from fastapi import FastAPI from nevus import __version__ +from nevus.api.auth import router as auth_router from nevus.api.health import router as health_router +from nevus.api.persons import router as persons_router +from nevus.api.users import router as users_router +from nevus.auth.ratelimit import LoginRateLimiter +from nevus.auth.service import bootstrap_admin from nevus.config import Settings, get_settings from nevus.db.engine import make_engine, make_session_factory +from nevus.db.migrate import upgrade_to_head from nevus.logging import configure_logging, get_logger +from nevus.web.csrf import CsrfMiddleware from nevus.web.security import HostAllowlistMiddleware, SecurityHeadersMiddleware from nevus.web.static import mount_frontend @@ -23,7 +30,13 @@ def create_app(settings: Settings | None = None) -> FastAPI: settings = settings or get_settings() configure_logging(settings.log_level) settings.data_dir.mkdir(parents=True, exist_ok=True) + if settings.auto_migrate: + upgrade_to_head(settings.effective_database_url) engine = make_engine(settings.effective_database_url) + session_factory = make_session_factory(engine) + with session_factory() as db: + bootstrap_admin(db, settings) + db.commit() @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: @@ -48,12 +61,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: ) app.state.settings = settings app.state.engine = engine - app.state.session_factory = make_session_factory(engine) + app.state.session_factory = session_factory + app.state.login_limiter = LoginRateLimiter(settings.login_attempts, settings.login_window_minutes * 60) app.add_middleware(SecurityHeadersMiddleware) + app.add_middleware(CsrfMiddleware) app.add_middleware(HostAllowlistMiddleware, allowed_hosts=settings.allowed_hosts) app.include_router(health_router) + app.include_router(auth_router) + app.include_router(users_router) + app.include_router(persons_router) mount_frontend(app, _static_dir(settings)) return app From 2639471683b530b4a0c1c2bd075049f2295d61ff Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:43:59 +0200 Subject: [PATCH 4/5] test: cover accounts, sessions, CSRF, rate limiting, administration and person access Twenty-four tests exercised on SQLite and PostgreSQL through the API; the development guide documents the claim flow and the bootstrap variables. Signed-off-by: Jose David --- DEVELOPMENT.md | 2 + backend/tests/__init__.py | 0 backend/tests/test_accounts.py | 131 +++++++++++++++++++++++++++++++++ backend/tests/test_persons.py | 77 +++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/test_accounts.py create mode 100644 backend/tests/test_persons.py diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d257a1d..010041f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -33,6 +33,8 @@ Without `just`: run the commands in `justfile` by hand; they are one line each. Every setting is an environment variable prefixed with `NEVUS_` (see `backend/src/nevus/config.py`). A checkout needs none: data goes to `./data`, SQLite is used, and the server answers to private addresses, `localhost` and `.local` names. Set `NEVUS_ALLOWED_HOSTS` to answer to a public hostname behind a reverse proxy, and `NEVUS_DATABASE_URL` to use an external PostgreSQL. +A fresh instance has no accounts: the first person to open it claims it and becomes the administrator (`POST /api/auth/claim`). Alternatively set `NEVUS_ADMIN_USER` and `NEVUS_ADMIN_PASSWORD` before the first start; they are read at start-up only, and setting them again resets that account's password, which is the documented way back in after a forgotten one. Pending database migrations are applied at start-up (`NEVUS_AUTO_MIGRATE=false` disables that). + ## Tests - Backend: `pytest` against a temporary data directory. Continuous integration runs the same suite twice, on SQLite and on PostgreSQL, plus `alembic upgrade head` and `alembic check` on both engines. diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py new file mode 100644 index 0000000..79d41b2 --- /dev/null +++ b/backend/tests/test_accounts.py @@ -0,0 +1,131 @@ +"""Claiming, signing in, sessions, sudo mode, CSRF, rate limiting and account administration.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from nevus.auth.service import SESSION_COOKIE + +ADMIN = {"username": "Jose", "password": "correct horse battery"} +MEMBER = {"username": "ana", "password": "another long password"} + + +def claim(client: TestClient) -> None: + response = client.post("/api/auth/claim", json=ADMIN) + assert response.status_code == 201, response.text + + +def test_instance_starts_unclaimed_and_the_first_person_becomes_admin(client: TestClient) -> None: + assert client.get("/api/auth/instance").json()["claimed"] is False + response = client.post("/api/auth/claim", json=ADMIN) + assert response.status_code == 201 + body = response.json() + assert body["user"]["username"] == "jose" # usernames are normalised to lowercase + assert body["user"]["role"] == "admin" + assert SESSION_COOKIE in client.cookies + assert client.get("/api/auth/instance").json()["claimed"] is True + # a second claim is refused + assert client.post("/api/auth/claim", json=MEMBER).status_code == 409 + + +def test_session_endpoint_requires_a_cookie_and_logout_revokes_it(client: TestClient) -> None: + assert client.get("/api/auth/session").status_code == 401 + claim(client) + assert client.get("/api/auth/session").status_code == 200 + assert client.post("/api/auth/logout").status_code == 204 + assert client.get("/api/auth/session").status_code == 401 + + +def test_login_with_wrong_password_fails_and_is_rate_limited(client: TestClient, settings) -> None: # type: ignore[no-untyped-def] + claim(client) + client.post("/api/auth/logout") + for _ in range(settings.login_attempts): + assert client.post("/api/auth/login", json={**ADMIN, "password": "wrong password here"}).status_code == 401 + assert client.post("/api/auth/login", json=ADMIN).status_code == 429 + + +def test_login_works_and_short_passwords_are_rejected(client: TestClient) -> None: + claim(client) + client.post("/api/auth/logout") + assert client.post("/api/auth/login", json=ADMIN).status_code == 200 + assert client.post("/api/auth/login", json={"username": "x", "password": "short"}).status_code == 422 + + +def test_cross_site_unsafe_requests_are_refused(client: TestClient) -> None: + claim(client) + response = client.post("/api/persons", json={"display_name": "Ana"}, headers={"sec-fetch-site": "cross-site"}) + assert response.status_code == 403 + response = client.post("/api/persons", json={"display_name": "Ana"}, headers={"sec-fetch-site": "same-origin"}) + assert response.status_code == 201 + response = client.post("/api/persons", json={"display_name": "Bea"}, headers={"origin": "https://evil.example.com"}) + assert response.status_code == 403 + + +def test_sudo_is_required_for_destructive_actions(client: TestClient) -> None: + claim(client) + person = client.post("/api/persons", json={"display_name": "Ana"}).json() + assert client.delete(f"/api/persons/{person['id']}").status_code == 403 + assert client.post("/api/auth/sudo", json={"password": "wrong password here"}).status_code == 401 + sudo = client.post("/api/auth/sudo", json={"password": ADMIN["password"]}) + assert sudo.status_code == 200 and sudo.json()["sudo_until"] + assert client.delete(f"/api/persons/{person['id']}").status_code == 204 + assert client.get(f"/api/persons/{person['id']}").status_code == 404 + + +def test_only_admins_manage_users_and_disabling_ends_sessions(client: TestClient) -> None: + claim(client) + created = client.post("/api/users", json={**MEMBER, "role": "member"}) + assert created.status_code == 201 + member_id = created.json()["id"] + assert client.post("/api/users", json=MEMBER).status_code == 409 + assert len(client.get("/api/users").json()) == 2 + + member = TestClient(client.app, base_url="http://localhost") + assert member.post("/api/auth/login", json=MEMBER).status_code == 200 + assert member.get("/api/users").status_code == 403 + assert member.post("/api/users", json={"username": "eve", "password": "long enough password"}).status_code == 403 + + assert client.post(f"/api/users/{member_id}/disable").status_code == 200 + assert member.get("/api/auth/session").status_code == 401 + assert member.post("/api/auth/login", json=MEMBER).status_code == 401 + assert client.post(f"/api/users/{member_id}/enable").status_code == 200 + assert member.post("/api/auth/login", json=MEMBER).status_code == 200 + + +def test_admin_password_reset_and_own_password_change(client: TestClient) -> None: + claim(client) + member_id = client.post("/api/users", json=MEMBER).json()["id"] + assert ( + client.post(f"/api/users/{member_id}/password", json={"new_password": "brand new password"}).status_code == 204 + ) + member = TestClient(client.app, base_url="http://localhost") + assert member.post("/api/auth/login", json=MEMBER).status_code == 401 + assert member.post("/api/auth/login", json={**MEMBER, "password": "brand new password"}).status_code == 200 + change = member.post( + "/api/auth/me/password", json={"current_password": "brand new password", "new_password": "my own new password"} + ) + assert change.status_code == 204 + assert member.get("/api/auth/session").status_code == 200 # the current session survives a password change + + +def test_preferences_are_stored_with_the_account(client: TestClient) -> None: + claim(client) + response = client.patch("/api/auth/me", json={"language": "es", "theme": "dark", "show_uncertainty": False}) + assert response.status_code == 200 + user = client.get("/api/auth/session").json()["user"] + assert (user["language"], user["theme"], user["show_uncertainty"]) == ("es", "dark", False) + assert client.patch("/api/auth/me", json={"language": "fr"}).status_code == 422 + + +def test_admin_from_environment_is_created_at_startup(tmp_path, settings) -> None: # type: ignore[no-untyped-def] + from nevus.app import create_app + + settings.admin_user = "boot" + settings.admin_password = "bootstrap password long" + with TestClient(create_app(settings), base_url="http://localhost") as client: + assert client.get("/api/auth/instance").json()["claimed"] is True + assert ( + client.post("/api/auth/login", json={"username": "boot", "password": "bootstrap password long"}).status_code + == 200 + ) + assert client.get("/api/auth/session").json()["user"]["role"] == "admin" diff --git a/backend/tests/test_persons.py b/backend/tests/test_persons.py new file mode 100644 index 0000000..f9cb3d4 --- /dev/null +++ b/backend/tests/test_persons.py @@ -0,0 +1,77 @@ +"""Persons and per-person access: owner, manager, viewer.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.test_accounts import ADMIN, MEMBER, claim + + +def login_member(client: TestClient) -> TestClient: + assert client.post("/api/users", json=MEMBER).status_code == 201 + member = TestClient(client.app, base_url="http://localhost") + assert member.post("/api/auth/login", json=MEMBER).status_code == 200 + return member + + +def test_persons_are_scoped_to_the_users_that_may_see_them(client: TestClient) -> None: + claim(client) + member = login_member(client) + person = client.post("/api/persons", json={"display_name": " Ana ", "birth_year": 2015}).json() + assert person["display_name"] == "Ana" and person["my_role"] == "owner" + assert member.get("/api/persons").json() == [] + assert member.get(f"/api/persons/{person['id']}").status_code == 404 + assert member.patch(f"/api/persons/{person['id']}", json={"display_name": "Eve"}).status_code == 404 + + +def test_owner_shares_a_person_and_roles_limit_what_others_can_do(client: TestClient) -> None: + claim(client) + member = login_member(client) + person_id = client.post("/api/persons", json={"display_name": "Ana"}).json()["id"] + + granted = client.put(f"/api/persons/{person_id}/access", json={"username": "ana", "role": "viewer"}) + assert granted.status_code == 200 and granted.json()["role"] == "viewer" + assert member.get(f"/api/persons/{person_id}").json()["my_role"] == "viewer" + assert member.patch(f"/api/persons/{person_id}", json={"display_name": "Eve"}).status_code == 403 + + client.put(f"/api/persons/{person_id}/access", json={"username": "ana", "role": "manager"}) + assert member.patch(f"/api/persons/{person_id}", json={"display_name": "Ana MarĂ­a"}).status_code == 200 + # the experimental-analysis switch belongs to the owner alone + assert member.patch(f"/api/persons/{person_id}", json={"experimental_analysis": True}).status_code == 403 + assert ( + client.patch(f"/api/persons/{person_id}", json={"experimental_analysis": True}).json()["experimental_analysis"] + is True + ) + # managers cannot share or delete + assert ( + member.put(f"/api/persons/{person_id}/access", json={"username": "jose", "role": "viewer"}).status_code == 403 + ) + member.post("/api/auth/sudo", json={"password": MEMBER["password"]}) + assert member.delete(f"/api/persons/{person_id}").status_code == 403 + + roles = {row["username"]: row["role"] for row in client.get(f"/api/persons/{person_id}/access").json()} + assert roles == {"jose": "owner", "ana": "manager"} + assert ( + client.delete( + f"/api/persons/{person_id}/access/{client.get('/api/auth/session').json()['user']['id']}" + ).status_code + == 400 + ) + member_id = member.get("/api/auth/session").json()["user"]["id"] + assert client.delete(f"/api/persons/{person_id}/access/{member_id}").status_code == 204 + assert member.get(f"/api/persons/{person_id}").status_code == 404 + + +def test_validation_of_person_fields(client: TestClient) -> None: + claim(client) + assert client.post("/api/persons", json={"display_name": " "}).status_code == 422 + assert client.post("/api/persons", json={"display_name": "Ana", "birth_year": 1800}).status_code == 422 + assert ( + client.put( + "/api/persons/00000000-0000-0000-0000-000000000000/access", json={"username": "x", "role": "owner"} + ).status_code + == 422 + ) + + +__all__ = ["ADMIN"] From 17aba25d9ae258539df5a47ed9f915e553e97635 Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 16:47:39 +0200 Subject: [PATCH 5/5] test: recreate the schema before each test on an external database SQLite gets a fresh file per test; a shared PostgreSQL kept state between tests, so the claim of the first test made later claims fail. Signed-off-by: Jose David --- backend/tests/conftest.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index feb483f..703bacb 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,14 +5,27 @@ import pytest from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text from nevus.app import create_app from nevus.config import Settings +def _reset_database(url: str) -> None: + """SQLite gets a fresh file per test; an external PostgreSQL is shared, so its schema is recreated.""" + engine = create_engine(url, isolation_level="AUTOCOMMIT") + with engine.connect() as connection: + connection.execute(text("DROP SCHEMA public CASCADE")) + connection.execute(text("CREATE SCHEMA public")) + engine.dispose() + + @pytest.fixture def settings(tmp_path: Path) -> Settings: - return Settings(data_dir=tmp_path / "data", allowed_hosts=["nevus.example.test"], log_level="warning") + settings = Settings(data_dir=tmp_path / "data", allowed_hosts=["nevus.example.test"], log_level="warning") + if not settings.is_sqlite: + _reset_database(settings.effective_database_url) + return settings @pytest.fixture