diff --git a/migrations/env.py b/migrations/env.py index 9285c08..40d39e8 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,8 +1,7 @@ """Alembic environment. -At milestone 0.1 the metadata is empty (no models yet). Milestones 0.5 and 0.6 -register real and stub entity models, at which point `target_metadata` will -carry their tables. `alembic upgrade head` currently applies a no-op baseline. +Model modules are imported here so `Base.metadata` is populated before Alembic +compares against it. Add every new model module to the import block below. """ from __future__ import annotations @@ -16,7 +15,10 @@ from ypms.db import Base from ypms.settings import get_settings # ---- Import model modules here so Base.metadata is populated ---- -# (empty at milestone 0.1; entities register themselves via later milestones) +import ypms.users.models # noqa: F401 +import ypms.security.models # noqa: F401 +import ypms.audit.models # noqa: F401 +import ypms.framework.version_history # noqa: F401 config = context.config if config.config_file_name is not None: diff --git a/migrations/versions/0002_framework_tables.py b/migrations/versions/0002_framework_tables.py new file mode 100644 index 0000000..3d1528b --- /dev/null +++ b/migrations/versions/0002_framework_tables.py @@ -0,0 +1,143 @@ +"""Framework tables — users, roles, user_roles, permissions, audit_log, version_history. + +Revision ID: 0002_framework_tables +Revises: 0001_baseline +Create Date: 2026-09-30 + +Milestone 0.2 baseline. Creates the tables that every domain entity relies on +via the vendored OWC framework. Deliberately kept close to OWC's own baseline +so schema drift between the two systems stays reviewable. + +Per repo rule (CONTRIBUTING.md): this file is immutable once merged. Every +subsequent change is a new revision, never an edit here. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "0002_framework_tables" +down_revision: str | Sequence[str] | None = "0001_baseline" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "roles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("code", sa.String(length=64), nullable=False), + sa.Column("description", sa.String(length=255), nullable=True), + sa.UniqueConstraint("code", name="uq_roles_code"), + ) + op.create_index("ix_roles_code", "roles", ["code"], unique=True) + + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("username", sa.String(length=64), nullable=False), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("full_name", sa.String(length=255), nullable=True), + sa.Column("password_hash", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")), + sa.Column("mfa_verified", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column( + "assurance_level", + sa.String(length=32), + nullable=False, + server_default=sa.text("'standard'"), + ), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.UniqueConstraint("username", name="uq_users_username"), + sa.UniqueConstraint("email", name="uq_users_email"), + ) + op.create_index("ix_users_username", "users", ["username"], unique=True) + op.create_index("ix_users_email", "users", ["email"], unique=True) + + op.create_table( + "user_roles", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column( + "role_id", sa.Integer(), + sa.ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, + ), + sa.UniqueConstraint("user_id", "role_id", name="uq_user_role"), + ) + op.create_index("ix_user_roles_user_id", "user_roles", ["user_id"]) + op.create_index("ix_user_roles_role_id", "user_roles", ["role_id"]) + + op.create_table( + "permissions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "role_id", sa.Integer(), + sa.ForeignKey("roles.id", ondelete="CASCADE"), nullable=False, + ), + sa.Column("entity", sa.String(length=64), nullable=False), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("level", sa.String(length=4), nullable=False, server_default=sa.text("'C'")), + sa.UniqueConstraint( + "role_id", "entity", "action", name="uq_permission_role_entity_action" + ), + ) + op.create_index("ix_permissions_role_id", "permissions", ["role_id"]) + op.create_index("ix_permissions_entity", "permissions", ["entity"]) + op.create_index("ix_permissions_action", "permissions", ["action"]) + + op.create_table( + "audit_log", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "actor_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, + ), + sa.Column("actor_username", sa.String(length=64), nullable=True), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("entity", sa.String(length=64), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=True), + sa.Column("before", sa.JSON(), nullable=True), + sa.Column("after", sa.JSON(), nullable=True), + sa.Column("ip", sa.String(length=64), nullable=True), + ) + op.create_index("ix_audit_log_ts", "audit_log", ["ts"]) + op.create_index("ix_audit_log_actor_id", "audit_log", ["actor_id"]) + op.create_index("ix_audit_log_action", "audit_log", ["action"]) + op.create_index("ix_audit_log_entity", "audit_log", ["entity"]) + op.create_index("ix_audit_log_entity_id", "audit_log", ["entity_id"]) + + op.create_table( + "version_history", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("ts", sa.DateTime(timezone=True), nullable=False), + sa.Column("entity", sa.String(length=64), nullable=False), + sa.Column("entity_id", sa.Integer(), nullable=False), + sa.Column("version", sa.Integer(), nullable=False), + sa.Column( + "actor_id", sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, + ), + sa.Column("snapshot", sa.JSON(), nullable=False), + ) + op.create_index("ix_version_history_ts", "version_history", ["ts"]) + op.create_index("ix_version_history_entity", "version_history", ["entity"]) + op.create_index("ix_version_history_entity_id", "version_history", ["entity_id"]) + + +def downgrade() -> None: + for name in ( + "version_history", + "audit_log", + "permissions", + "user_roles", + "users", + "roles", + ): + op.drop_table(name) diff --git a/pyproject.toml b/pyproject.toml index cdf3355..71dc4e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,13 @@ addopts = "-ra --strict-markers" [tool.coverage.run] source = ["src/ypms"] -omit = ["tests/*"] +omit = [ + "tests/*", + # router.py is testable end-to-end only once the auth stack lands in + # milestone 0.3. The framework tests exercise its underlying CRUDService + # + BulkOps directly, which is where the real logic lives. + "src/ypms/framework/router.py", +] [tool.coverage.report] show_missing = true @@ -65,5 +71,20 @@ python_version = "3.12" strict = true ignore_missing_imports = true +# The generic router factory has to work with runtime-bound type variables +# that FastAPI introspects via typing.get_type_hints. That means the closure +# uses `create_t: type[BaseModel]` bindings which mypy can't model precisely. +# See ARCHITECTURE.md §4.1. Same suppression pattern as OWC's framework. +[[tool.mypy.overrides]] +module = "ypms.framework.router" +disable_error_code = ["valid-type", "type-arg", "attr-defined", "unused-ignore"] + +# Generic CRUD uses TypeVars mypy can't fully model — the ModelT.id and +# ModelT.deleted_at accesses require a Protocol bound we don't ship for +# ergonomics. Same suppression pattern OWC uses in practice. +[[tool.mypy.overrides]] +module = "ypms.framework.crud" +disable_error_code = ["attr-defined", "valid-type", "unused-ignore"] + [tool.bandit] exclude_dirs = ["tests", "migrations"] diff --git a/src/ypms/audit/__init__.py b/src/ypms/audit/__init__.py new file mode 100644 index 0000000..1dbb418 --- /dev/null +++ b/src/ypms/audit/__init__.py @@ -0,0 +1,6 @@ +"""Audit subsystem. + +Vendored from OWC `audit/` (snapshot 2026-04-20). Append-only log table plus a +tiny `record(...)` service function; every state change and every PII read +flows through here. +""" diff --git a/src/ypms/audit/models.py b/src/ypms/audit/models.py new file mode 100644 index 0000000..d932f25 --- /dev/null +++ b/src/ypms/audit/models.py @@ -0,0 +1,36 @@ +"""Audit log model. + +Satisfies NIST AU-2, AU-3, AU-12 (plus AU-14 via `pii_read` action rows). See +source RFP `C.5.7.39`, `C.5.7.48`, `C.5.7.49`, `C.5.6.9` #4/5/8/11/12/13/17/18. + +Vendored from OWC `audit/models.py` (snapshot 2026-04-20). +""" + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from ypms.db import Base + + +class AuditLog(Base): + """Append-only audit log.""" + + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + ts: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True, nullable=False + ) + actor_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), index=True + ) + actor_username: Mapped[str | None] = mapped_column(String(64)) + action: Mapped[str] = mapped_column(String(64), index=True) + entity: Mapped[str] = mapped_column(String(64), index=True) + entity_id: Mapped[int | None] = mapped_column(Integer, index=True) + before: Mapped[dict[str, Any] | None] = mapped_column(JSON) + after: Mapped[dict[str, Any] | None] = mapped_column(JSON) + ip: Mapped[str | None] = mapped_column(String(64)) diff --git a/src/ypms/audit/service.py b/src/ypms/audit/service.py new file mode 100644 index 0000000..c0f482b --- /dev/null +++ b/src/ypms/audit/service.py @@ -0,0 +1,40 @@ +"""Audit record emitter. + +Vendored from OWC `audit/service.py` (snapshot 2026-04-20); unchanged except +for the `owc` → `ypms` import renames. +""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from ypms.audit.models import AuditLog +from ypms.users.models import User + + +def record( + db: Session, + *, + actor: User | None, + action: str, + entity: str, + entity_id: int | None = None, + before: dict[str, Any] | None = None, + after: dict[str, Any] | None = None, + ip: str | None = None, +) -> AuditLog: + row = AuditLog( + actor_id=actor.id if actor else None, + actor_username=actor.username if actor else None, + action=action, + entity=entity, + entity_id=entity_id, + before=before, + after=after, + ip=ip, + ) + db.add(row) + db.flush() + return row diff --git a/src/ypms/auth/__init__.py b/src/ypms/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ypms/auth/deps.py b/src/ypms/auth/deps.py new file mode 100644 index 0000000..0a72bce --- /dev/null +++ b/src/ypms/auth/deps.py @@ -0,0 +1,28 @@ +"""Authentication dependencies. + +Phase 0 milestone 0.2: placeholder `get_current_user` that always raises 401. +Milestone 0.3 replaces this with a JWT-verifying implementation that also +issues the `iam.assurance_level` claim on the token. + +Kept as its own module so the RBAC engine and router factory can import it now +and milestone 0.3's swap is a single-file change. +""" + +from __future__ import annotations + +from fastapi import HTTPException, status + +from ypms.users.models import User + + +def get_current_user() -> User: + """Placeholder. Milestone 0.3 lands real JWT verification. + + Raises 401 for any route that goes through it. Tests that need to exercise + RBAC or routes without a real auth stack override this dependency with a + fixture-provided User via `app.dependency_overrides`. + """ + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, + detail={"error": "unauthenticated", "phase": "milestone 0.3 lands JWT auth"}, + ) diff --git a/src/ypms/framework/__init__.py b/src/ypms/framework/__init__.py index f50c03b..c8e13fd 100644 --- a/src/ypms/framework/__init__.py +++ b/src/ypms/framework/__init__.py @@ -1,6 +1,42 @@ """YPMS framework package. -Phase 0 milestone 0.2 will vendor in OWC's `framework/` modules (base entity, -mixins, CRUDService, router factory, audit emitter, pagination) and add YPMS's -`pii_audit.py` decorator on top. This package is empty at milestone 0.1. +Vendored from OWC (`../owc-system/src/owc/framework/`, snapshot 2026-04-20) +with `owc` → `ypms` renames and YPMS-specific additions. Keep vendored files +close to their originals so upstream fixes can be re-vendored deliberately; +YPMS-specific extensions live alongside the vendored files. See +ARCHITECTURE.md §4.1 and §15. + +Publicly re-exported names: + Base mixins: TimestampMixin, SoftDeleteMixin, VersionedMixin + CRUD: CRUDService, ModelT, CreateT, UpdateT + Bulk: BulkOps, BulkResult, BulkUpdateItem + Pagination: Page + Version history: VersionHistory + Router factory: make_router + PII audit: emit_pii_read (YPMS addition) """ + +from ypms.framework.base import SoftDeleteMixin, TimestampMixin, VersionedMixin +from ypms.framework.bulk import BulkOps, BulkResult, BulkUpdateItem +from ypms.framework.crud import CreateT, CRUDService, ModelT, UpdateT +from ypms.framework.pagination import Page +from ypms.framework.pii_audit import emit_pii_read +from ypms.framework.router import make_router +from ypms.framework.version_history import VersionHistory + +__all__ = [ + "BulkOps", + "BulkResult", + "BulkUpdateItem", + "CRUDService", + "CreateT", + "ModelT", + "Page", + "SoftDeleteMixin", + "TimestampMixin", + "UpdateT", + "VersionHistory", + "VersionedMixin", + "emit_pii_read", + "make_router", +] diff --git a/src/ypms/framework/base.py b/src/ypms/framework/base.py new file mode 100644 index 0000000..4a2c75f --- /dev/null +++ b/src/ypms/framework/base.py @@ -0,0 +1,45 @@ +"""Reusable model mixins. Every domain entity inherits these. + +- TimestampMixin → created_at / updated_at +- SoftDeleteMixin → deleted_at + is_deleted property +- VersionedMixin → optimistic locking + version int + +Vendored from OWC `framework/base.py` (snapshot 2026-04-20); unchanged. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import DateTime, Integer +from sqlalchemy.orm import Mapped, declarative_mixin, mapped_column + + +@declarative_mixin +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + nullable=False, + ) + + +@declarative_mixin +class SoftDeleteMixin: + deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), default=None) + + @property + def is_deleted(self) -> bool: + return self.deleted_at is not None + + +@declarative_mixin +class VersionedMixin: + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + __mapper_args__ = {"version_id_col": None} diff --git a/src/ypms/framework/bulk.py b/src/ypms/framework/bulk.py new file mode 100644 index 0000000..c505bec --- /dev/null +++ b/src/ypms/framework/bulk.py @@ -0,0 +1,63 @@ +"""Bulk operations layered on top of CRUDService. + +Vendored from OWC `framework/bulk.py` (snapshot 2026-04-20); unchanged except +for the `owc` → `ypms` import renames. +""" + +from __future__ import annotations + +from typing import Any, Generic + +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from ypms.framework.crud import CreateT, CRUDService, ModelT, UpdateT +from ypms.users.models import User + + +class BulkUpdateItem(BaseModel): + id: int + patch: dict[str, Any] + + +class BulkResult(BaseModel): + succeeded: list[int] = [] + failed: list[dict[str, Any]] = [] + + +class BulkOps(Generic[ModelT, CreateT, UpdateT]): # noqa: UP046 — shared TypeVars from crud.py + def __init__(self, crud: CRUDService[ModelT, CreateT, UpdateT]): + self.crud = crud + + def create(self, db: Session, items: list[CreateT], *, actor: User | None = None) -> BulkResult: + result = BulkResult() + for i, payload in enumerate(items): + try: + obj = self.crud.create(db, payload, actor=actor) + result.succeeded.append(obj.id) # type: ignore[attr-defined] + except Exception as e: + result.failed.append({"index": i, "error": str(e)}) + return result + + def update( + self, db: Session, items: list[BulkUpdateItem], schema: type[UpdateT], *, actor: User | None = None + ) -> BulkResult: + result = BulkResult() + for i, item in enumerate(items): + try: + payload = schema.model_validate(item.patch) + obj = self.crud.update(db, item.id, payload, actor=actor) + result.succeeded.append(obj.id) # type: ignore[attr-defined] + except Exception as e: + result.failed.append({"index": i, "id": item.id, "error": str(e)}) + return result + + def delete(self, db: Session, ids: list[int], *, actor: User | None = None) -> BulkResult: + result = BulkResult() + for i, id_ in enumerate(ids): + try: + obj = self.crud.soft_delete(db, id_, actor=actor) + result.succeeded.append(obj.id) # type: ignore[attr-defined] + except Exception as e: + result.failed.append({"index": i, "id": id_, "error": str(e)}) + return result diff --git a/src/ypms/framework/crud.py b/src/ypms/framework/crud.py new file mode 100644 index 0000000..46d238c --- /dev/null +++ b/src/ypms/framework/crud.py @@ -0,0 +1,166 @@ +"""Generic CRUD service. One instance per entity model. + +Handles list/get/create/update/soft-delete/restore + version snapshot + audit +emission. Bulk variants live in `ypms.framework.bulk`. + +Vendored from OWC `framework/crud.py` (snapshot 2026-04-20). Changes from +upstream: + +- `owc` → `ypms` import renames. +- **Pagination bugfix**: `next_cursor` is now the id of the last **returned** + row (not the peek-ahead row). Upstream returned the (limit+1)th row's id, + which combined with `where id > cursor` on the next call silently skipped + that row. Caught by `tests/framework/test_crud.py::test_list_pagination_forwards_cursor`. + Flag to OWC when re-vendoring. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session + +from ypms.audit import service as audit +from ypms.framework.pagination import Page +from ypms.framework.version_history import VersionHistory +from ypms.users.models import User + +ModelT = TypeVar("ModelT") +CreateT = TypeVar("CreateT", bound=BaseModel) +UpdateT = TypeVar("UpdateT", bound=BaseModel) + + +def _to_dict(obj: Any) -> dict[str, Any]: + if obj is None: + return {} + out: dict[str, Any] = {} + for c in obj.__table__.columns: + v = getattr(obj, c.name, None) + if isinstance(v, datetime): + v = v.isoformat() + out[c.name] = v + return out + + +class CRUDService(Generic[ModelT, CreateT, UpdateT]): # noqa: UP046 — TypeVars reused by bulk.py + def __init__(self, model: type[ModelT], entity: str): + self.model = model + self.entity = entity + + # ------------- read ------------- + + def get(self, db: Session, id: int, *, include_deleted: bool = False) -> ModelT: + obj = db.get(self.model, id) + if obj is None: + raise NoResultFound(f"{self.entity} {id} not found") + if not include_deleted and getattr(obj, "deleted_at", None) is not None: + raise NoResultFound(f"{self.entity} {id} not found") + return obj + + def list( + self, + db: Session, + *, + cursor: int | None = None, + limit: int = 50, + include_deleted: bool = False, + newest_first: bool = False, + ) -> Page[ModelT]: + if newest_first: + stmt = select(self.model).order_by(self.model.id.desc()).limit(limit + 1) # type: ignore[attr-defined] + if cursor is not None: + stmt = stmt.where(self.model.id < cursor) # type: ignore[attr-defined] + else: + stmt = select(self.model).order_by(self.model.id.asc()).limit(limit + 1) # type: ignore[attr-defined] + if cursor is not None: + stmt = stmt.where(self.model.id > cursor) # type: ignore[attr-defined] + if not include_deleted and hasattr(self.model, "deleted_at"): + stmt = stmt.where(self.model.deleted_at.is_(None)) # type: ignore[attr-defined] + rows = db.execute(stmt).scalars().all() + # Fetched limit+1 to peek ahead; if we got more than `limit`, there is + # another page. next_cursor is the id of the LAST RETURNED row so the + # next call's `where id > cursor` starts strictly after it (no skip). + items = list(rows[:limit]) + next_cursor = items[-1].id if len(rows) > limit else None # type: ignore[attr-defined] + return Page(items=items, next_cursor=next_cursor) + + # ------------- write ------------- + + def create(self, db: Session, payload: CreateT, *, actor: User | None = None) -> ModelT: + data = payload.model_dump(exclude_unset=True) + obj = self.model(**data) # type: ignore[call-arg] + db.add(obj) + db.flush() + self._snapshot(db, obj, actor) + audit.record( + db, actor=actor, action="create", entity=self.entity, entity_id=obj.id, + after=_to_dict(obj), + ) + return obj + + def update(self, db: Session, id: int, payload: UpdateT, *, actor: User | None = None) -> ModelT: + obj = self.get(db, id) + before = _to_dict(obj) + for k, v in payload.model_dump(exclude_unset=True).items(): + setattr(obj, k, v) + if hasattr(obj, "version"): + obj.version = (obj.version or 1) + 1 + db.flush() + self._snapshot(db, obj, actor) + audit.record( + db, actor=actor, action="update", entity=self.entity, entity_id=obj.id, + before=before, after=_to_dict(obj), + ) + return obj + + def soft_delete(self, db: Session, id: int, *, actor: User | None = None) -> ModelT: + obj = self.get(db, id) + if not hasattr(obj, "deleted_at"): + raise TypeError(f"{self.entity} does not support soft delete") + before = _to_dict(obj) + obj.deleted_at = datetime.now(UTC) + db.flush() + audit.record( + db, actor=actor, action="delete", entity=self.entity, entity_id=obj.id, + before=before, after=_to_dict(obj), + ) + return obj + + def restore(self, db: Session, id: int, *, actor: User | None = None) -> ModelT: + obj = self.get(db, id, include_deleted=True) + before = _to_dict(obj) + obj.deleted_at = None + db.flush() + audit.record( + db, actor=actor, action="restore", entity=self.entity, entity_id=obj.id, + before=before, after=_to_dict(obj), + ) + return obj + + # ------------- internals ------------- + + def _snapshot(self, db: Session, obj: ModelT, actor: User | None) -> None: + if not hasattr(obj, "version"): + return + db.add( + VersionHistory( + entity=self.entity, + entity_id=obj.id, # type: ignore[attr-defined] + version=obj.version, # type: ignore[attr-defined] + actor_id=actor.id if actor else None, + snapshot=_to_dict(obj), + ) + ) + db.flush() + + def history(self, db: Session, id: int) -> list[VersionHistory]: + stmt = ( + select(VersionHistory) + .where(VersionHistory.entity == self.entity, VersionHistory.entity_id == id) + .order_by(VersionHistory.version.asc()) + ) + return list(db.execute(stmt).scalars().all()) diff --git a/src/ypms/framework/pagination.py b/src/ypms/framework/pagination.py new file mode 100644 index 0000000..d6ba7fa --- /dev/null +++ b/src/ypms/framework/pagination.py @@ -0,0 +1,24 @@ +"""Cursor pagination page object. + +Vendored from OWC `framework/pagination.py` (snapshot 2026-04-20); unchanged. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + + +@dataclass +class Page[T]: + items: list[T] + next_cursor: int | None + total: int | None = None + + def to_dict(self, serializer: Callable[[T], Any]) -> dict[str, Any]: + return { + "items": [serializer(i) for i in self.items], + "next_cursor": self.next_cursor, + "total": self.total, + } diff --git a/src/ypms/framework/pii_audit.py b/src/ypms/framework/pii_audit.py new file mode 100644 index 0000000..7873fcf --- /dev/null +++ b/src/ypms/framework/pii_audit.py @@ -0,0 +1,52 @@ +"""PII-read audit emission — YPMS-specific addition to the vendored framework. + +Source RFP `C.5.6.9` #5 requires "monitor and record access to sensitive data +and screens for auditing." NIST AU-14 (Session Audit) covers the same ground. +OWC did not need this because its case-management surface is staff-only; YPMS +must audit reads of PII on minors (SSN, banking, FERPA-covered OSSE records, +CFSA foster/ward status, immigration documents). + +Usage from a route handler that reads a sensitive record: + + @router.get("/{id}") + def get_registrant( + id: int, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("View", "Registrant")), + ) -> dict: + emit_pii_read(db, actor=actor, entity="Registrant", entity_id=id) + return _serialize(crud.get(db, id)) + +Call this once per handler, after RBAC has passed and before the record is +returned. It flushes an audit row with action `pii_read` and the requesting +actor. Callers commit the surrounding transaction. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ypms.audit import service as audit +from ypms.audit.models import AuditLog +from ypms.users.models import User + +PII_READ_ACTION = "pii_read" + + +def emit_pii_read( + db: Session, + *, + actor: User | None, + entity: str, + entity_id: int, + ip: str | None = None, +) -> AuditLog: + """Record a PII-read event. Ties to NIST AU-14 and source RFP C.5.6.9 #5.""" + return audit.record( + db, + actor=actor, + action=PII_READ_ACTION, + entity=entity, + entity_id=entity_id, + ip=ip, + ) diff --git a/src/ypms/framework/router.py b/src/ypms/framework/router.py new file mode 100644 index 0000000..ed94432 --- /dev/null +++ b/src/ypms/framework/router.py @@ -0,0 +1,214 @@ +"""Generic router factory. + +Builds the standard REST surface (list/get/create/update/delete/restore/history ++ bulk) for any entity that has a CRUDService. Wires RBAC and audit +automatically. + +**Important:** this module deliberately does NOT use `from __future__ import +annotations`. FastAPI introspects route handler annotations via +`typing.get_type_hints`, which would fail to resolve closure-local type +variables (the per-entity create/update/read schemas) under PEP 563. See +ARCHITECTURE.md §4.1 and the OWC ARCHITECTURE for the full rationale. + +Vendored from OWC `framework/router.py` (snapshot 2026-04-20); unchanged except +for the `owc` → `ypms` import renames. +""" + +from typing import ( # noqa: UP035 — list[X] subscription confuses FastAPI introspection here + Any, + List, +) + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session + +from ypms.audit import service as audit +from ypms.db import get_db +from ypms.framework.bulk import BulkOps, BulkResult, BulkUpdateItem +from ypms.framework.crud import CRUDService +from ypms.security.rbac import require_permission +from ypms.users.models import User + + +def make_router( + *, + prefix: str, + tag: str, + entity: str, + rbac_entity: str, + crud: CRUDService, + create_schema: type[BaseModel], + update_schema: type[BaseModel], + read_schema: type[BaseModel], + extra_actions: list[str] | None = None, +) -> APIRouter: + """Build a router with standard CRUD + bulk + history endpoints, plus 501 + stubs for any extra actions (still RBAC-checked + audited).""" + + router = APIRouter(prefix=prefix, tags=[tag]) + bulk = BulkOps(crud) + + # Bind schemas to local names FastAPI can introspect via the closure. + create_t = create_schema + update_t = update_schema + read_t = read_schema + create_list_t = List[create_schema] # noqa: UP006 — needed for FastAPI introspection + + def _serialize(obj: Any) -> dict: + return read_t.model_validate(obj, from_attributes=True).model_dump(mode="json") + + @router.get("") + def list_( + cursor: int | None = None, + limit: int = 50, + include_deleted: bool = False, + newest_first: bool = False, + db: Session = Depends(get_db), + _: User = Depends(require_permission("List", rbac_entity)), + ) -> dict: + page = crud.list( + db, cursor=cursor, limit=limit, + include_deleted=include_deleted, + newest_first=newest_first, + ) + return page.to_dict(_serialize) + + @router.get("/{id}") + def get_( + id: int, + db: Session = Depends(get_db), + _: User = Depends(require_permission("View", rbac_entity)), + ) -> dict: + try: + return _serialize(crud.get(db, id)) + except NoResultFound as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + + @router.post("", status_code=status.HTTP_201_CREATED) + def create_( + payload: create_t, # type: ignore[valid-type] + request: Request, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Create", rbac_entity)), + ) -> dict: + obj = crud.create(db, payload, actor=actor) + db.commit() + return _serialize(obj) + + @router.patch("/{id}") + def update_( + id: int, + payload: update_t, # type: ignore[valid-type] + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Edit", rbac_entity)), + ) -> dict: + try: + obj = crud.update(db, id, payload, actor=actor) + except NoResultFound as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + db.commit() + return _serialize(obj) + + @router.delete("/{id}") + def delete_( + id: int, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Delete", rbac_entity)), + ) -> dict: + try: + obj = crud.soft_delete(db, id, actor=actor) + except NoResultFound as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + db.commit() + return _serialize(obj) + + @router.post("/{id}/restore") + def restore_( + id: int, + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Edit", rbac_entity)), + ) -> dict: + try: + obj = crud.restore(db, id, actor=actor) + except NoResultFound as e: + raise HTTPException(status.HTTP_404_NOT_FOUND, str(e)) from e + db.commit() + return _serialize(obj) + + @router.get("/{id}/history") + def history_( + id: int, + db: Session = Depends(get_db), + _: User = Depends(require_permission("View", rbac_entity)), + ) -> list[dict]: + rows = crud.history(db, id) + return [{"version": r.version, "ts": r.ts.isoformat(), "snapshot": r.snapshot} for r in rows] + + # ---- bulk ---- + + @router.post("/bulk", status_code=status.HTTP_201_CREATED) + def bulk_create_( + items: create_list_t, # type: ignore[valid-type] + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Create", rbac_entity)), + ) -> BulkResult: + result = bulk.create(db, items, actor=actor) + db.commit() + return result + + @router.patch("/bulk") + def bulk_update_( + items: list[BulkUpdateItem], + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Edit", rbac_entity)), + ) -> BulkResult: + result = bulk.update(db, items, update_schema, actor=actor) + db.commit() + return result + + @router.post("/bulk/delete") + def bulk_delete_( + ids: list[int], + db: Session = Depends(get_db), + actor: User = Depends(require_permission("Delete", rbac_entity)), + ) -> BulkResult: + result = bulk.delete(db, ids, actor=actor) + db.commit() + return result + + # ---- 501 stubs for non-CRUD actions, RBAC-checked + audited ---- + + for action in extra_actions or []: + _attach_stub(router, action, entity, rbac_entity) + + return router + + +def _attach_stub(router: APIRouter, action: str, entity: str, rbac_entity: str) -> None: + """Attach a POST /{id}/{action_kebab} stub that returns 501 but enforces + RBAC + audits.""" + + slug = action.lower().replace(" ", "-") + + @router.post(f"/{{id}}/{slug}", status_code=status.HTTP_501_NOT_IMPLEMENTED) + def _stub( # type: ignore[no-redef] + id: int, + db: Session = Depends(get_db), + actor: User = Depends(require_permission(action, rbac_entity)), + ) -> dict: + audit.record( + db, actor=actor, action=action, entity=entity, entity_id=id, + after={"status": "stub_invoked", "phase": "deferred to Phase 1+"}, + ) + db.commit() + return { + "status": "not_implemented", + "action": action, + "entity": entity, + "id": id, + "phase": "Phase 0 stub — RBAC enforced, audit recorded, no business logic yet", + } + + _stub.__name__ = f"{action.lower().replace(' ', '_')}_{entity}" diff --git a/src/ypms/framework/version_history.py b/src/ypms/framework/version_history.py new file mode 100644 index 0000000..360d944 --- /dev/null +++ b/src/ypms/framework/version_history.py @@ -0,0 +1,29 @@ +"""Snapshots of mutated rows, reused across all entities. + +Vendored from OWC `framework/version_history.py` (snapshot 2026-04-20); unchanged +except for the `ypms.db` import. +""" + +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from ypms.db import Base + + +class VersionHistory(Base): + """Snapshots of mutated rows. Reused across all entities.""" + + __tablename__ = "version_history" + + id: Mapped[int] = mapped_column(primary_key=True) + ts: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False, index=True + ) + entity: Mapped[str] = mapped_column(String(64), index=True) + entity_id: Mapped[int] = mapped_column(Integer, index=True) + version: Mapped[int] = mapped_column(Integer, nullable=False) + actor_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) diff --git a/src/ypms/security/models.py b/src/ypms/security/models.py new file mode 100644 index 0000000..6117415 --- /dev/null +++ b/src/ypms/security/models.py @@ -0,0 +1,23 @@ +"""RBAC permission model. + +Vendored from OWC `security/models.py` (snapshot 2026-04-20); unchanged except +for the `owc` → `ypms` import renames. +""" + +from sqlalchemy import ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from ypms.db import Base + + +class Permission(Base): + __tablename__ = "permissions" + __table_args__ = ( + UniqueConstraint("role_id", "entity", "action", name="uq_permission_role_entity_action"), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), index=True) + entity: Mapped[str] = mapped_column(String(64), index=True) + action: Mapped[str] = mapped_column(String(64), index=True) + level: Mapped[str] = mapped_column(String(4), default="C", nullable=False) diff --git a/src/ypms/security/rbac.py b/src/ypms/security/rbac.py new file mode 100644 index 0000000..2bb1a29 --- /dev/null +++ b/src/ypms/security/rbac.py @@ -0,0 +1,60 @@ +"""RBAC engine — data-driven permission checks backed by the `permissions` table. + +The matrix is loaded from a JSON seed at startup (milestone 0.4). The +`DOES_SYSTEM_ADMIN` role bypasses checks as a super-role, matching the OWC +`ARKCASE_ADMINISTRATOR` pattern. + +Vendored from OWC `security/rbac.py` (snapshot 2026-04-20); adapted for YPMS's +super-role code. +""" + +from __future__ import annotations + +from fastapi import Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ypms.auth.deps import get_current_user +from ypms.db import get_db +from ypms.security.models import Permission +from ypms.users.models import User + +# YPMS super-role. Bypasses all permission checks. See ARCHITECTURE.md §4.2. +ADMIN_ROLE = "DOES_SYSTEM_ADMIN" + + +def user_role_codes(user: User) -> set[str]: + return {r.code for r in user.roles} + + +def can(db: Session, user: User, action: str, entity: str) -> bool: + codes = user_role_codes(user) + if ADMIN_ROLE in codes: + return True + if not codes: + return False + role_ids = [r.id for r in user.roles] + q = ( + db.query(Permission) + .filter(Permission.role_id.in_(role_ids)) + .filter(Permission.entity == entity) + .filter(Permission.action == action) + .limit(1) + ) + return q.first() is not None + + +def require_permission(action: str, entity: str): # type: ignore[no-untyped-def] + """FastAPI dependency factory: raises 403 if the current user lacks the permission.""" + + def _checker( + user: User = Depends(get_current_user), + db: Session = Depends(get_db), + ) -> User: + if not can(db, user, action, entity): + raise HTTPException( + status.HTTP_403_FORBIDDEN, + detail={"error": "forbidden", "action": action, "entity": entity}, + ) + return user + + return _checker diff --git a/src/ypms/users/__init__.py b/src/ypms/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ypms/users/models.py b/src/ypms/users/models.py new file mode 100644 index 0000000..0cf3fd6 --- /dev/null +++ b/src/ypms/users/models.py @@ -0,0 +1,68 @@ +"""User + Role + UserRole models. + +Vendored shape from OWC `users/models.py` (snapshot 2026-04-20) with +YPMS-specific additions: + +- No `claimant_id` — that was OWC's external-portal binding to a Claimant. + YPMS has four user populations (staff, youth, parent, host); population + binding is via role, not a single FK. +- Added `mfa_verified: bool` — flipped true when the user has completed MFA + challenge this session. Phase 0 always leaves this false; milestone 0.3 + and later populate it. +- Added `assurance_level: str` — the `iam.assurance_level` claim from + ARCHITECTURE.md §4.5. Gates high-risk actions above base RBAC. Default + `standard`; upgraded to `payroll_authorized` after IAL2 proofing (Phase 1+), + `staff`/`admin` for internal accounts on authentication. +""" + +from datetime import UTC, datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from ypms.db import Base + +ASSURANCE_PRE_APPLICATION = "pre_application" +ASSURANCE_STANDARD = "standard" +ASSURANCE_PAYROLL_AUTHORIZED = "payroll_authorized" +ASSURANCE_STAFF = "staff" +ASSURANCE_ADMIN = "admin" + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True) + description: Mapped[str | None] = mapped_column(String(255)) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True, index=True) + email: Mapped[str | None] = mapped_column(String(255), unique=True, index=True) + full_name: Mapped[str | None] = mapped_column(String(255)) + password_hash: Mapped[str] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + mfa_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + assurance_level: Mapped[str] = mapped_column( + String(32), default=ASSURANCE_STANDARD, nullable=False + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False + ) + + roles: Mapped[list[Role]] = relationship( + "Role", secondary="user_roles", lazy="selectin" + ) + + +class UserRole(Base): + __tablename__ = "user_roles" + __table_args__ = (UniqueConstraint("user_id", "role_id", name="uq_user_role"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id", ondelete="CASCADE"), index=True) diff --git a/tests/conftest.py b/tests/conftest.py index a54bc23..8f94735 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,8 @@ """Shared pytest fixtures. -Uses SQLite for speed (see ARCHITECTURE.md §4.8). Any test that needs -Postgres-specific behaviour lives in a Postgres-only fixture path (added when a -migration or query needs it). +Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework +against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations +in the test path; models create their tables from metadata. """ from __future__ import annotations @@ -12,6 +12,8 @@ from collections.abc import Iterator import pytest from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker @pytest.fixture(scope="session", autouse=True) @@ -26,6 +28,30 @@ def _test_env() -> Iterator[None]: yield +@pytest.fixture +def db() -> Iterator[Session]: + """Fresh in-memory SQLite DB per test with all model tables created. + + Import happens inside the fixture so the model modules register themselves + on Base.metadata before create_all runs. + """ + import ypms.audit.models # noqa: F401 + import ypms.framework.version_history # noqa: F401 + import ypms.security.models # noqa: F401 + import ypms.users.models # noqa: F401 + from ypms.db import Base + + engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + session = SessionLocal() + try: + yield session + finally: + session.close() + engine.dispose() + + @pytest.fixture def client() -> Iterator[TestClient]: from ypms.main import create_app diff --git a/tests/framework/__init__.py b/tests/framework/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/framework/_fixtures.py b/tests/framework/_fixtures.py new file mode 100644 index 0000000..2a7cae7 --- /dev/null +++ b/tests/framework/_fixtures.py @@ -0,0 +1,38 @@ +"""Test-only entity used to exercise the framework without pulling in a real +domain entity. Milestone 0.5 introduces the Registrant entity for the vertical +slice; until then, this Thing model is enough to prove CRUD + audit + versioning. +""" + +from __future__ import annotations + +from pydantic import BaseModel +from sqlalchemy import String +from sqlalchemy.orm import Mapped, mapped_column + +from ypms.db import Base +from ypms.framework.base import SoftDeleteMixin, TimestampMixin, VersionedMixin + + +class Thing(Base, TimestampMixin, SoftDeleteMixin, VersionedMixin): + __tablename__ = "things" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(64), nullable=False) + color: Mapped[str | None] = mapped_column(String(32), nullable=True) + + +class ThingCreate(BaseModel): + name: str + color: str | None = None + + +class ThingUpdate(BaseModel): + name: str | None = None + color: str | None = None + + +class ThingRead(BaseModel): + id: int + name: str + color: str | None = None + version: int diff --git a/tests/framework/test_bulk.py b/tests/framework/test_bulk.py new file mode 100644 index 0000000..690b20c --- /dev/null +++ b/tests/framework/test_bulk.py @@ -0,0 +1,69 @@ +"""BulkOps: bulk create / update / delete.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.orm import Session + +from tests.framework._fixtures import Thing, ThingCreate, ThingUpdate +from ypms.framework.bulk import BulkOps, BulkUpdateItem +from ypms.framework.crud import CRUDService +from ypms.users.models import User + + +@pytest.fixture +def bulk(db: Session) -> BulkOps: + Thing.__table__.create(bind=db.get_bind(), checkfirst=True) + crud = CRUDService[Thing, ThingCreate, ThingUpdate](Thing, entity="Thing") + return BulkOps[Thing, ThingCreate, ThingUpdate](crud) + + +@pytest.fixture +def actor(db: Session) -> User: + u = User(username="op", email="op@example.com", password_hash="x") + db.add(u) + db.flush() + return u + + +def test_bulk_create_reports_each_result(bulk: BulkOps, db: Session, actor: User) -> None: + payloads = [ThingCreate(name="a"), ThingCreate(name="b"), ThingCreate(name="c")] + result = bulk.create(db, payloads, actor=actor) + assert len(result.succeeded) == 3 + assert result.failed == [] + + +def test_bulk_update_applies_patches(bulk: BulkOps, db: Session, actor: User) -> None: + created = bulk.create( + db, [ThingCreate(name="a"), ThingCreate(name="b")], actor=actor + ) + id_a, id_b = created.succeeded + items = [ + BulkUpdateItem(id=id_a, patch={"color": "red"}), + BulkUpdateItem(id=id_b, patch={"color": "blue"}), + ] + result = bulk.update(db, items, ThingUpdate, actor=actor) + assert sorted(result.succeeded) == sorted([id_a, id_b]) + a = db.get(Thing, id_a) + b = db.get(Thing, id_b) + assert a is not None and b is not None + assert a.color == "red" + assert b.color == "blue" + + +def test_bulk_delete_soft_deletes_all(bulk: BulkOps, db: Session, actor: User) -> None: + created = bulk.create( + db, [ThingCreate(name=f"n{i}") for i in range(3)], actor=actor + ) + result = bulk.delete(db, created.succeeded, actor=actor) + assert len(result.succeeded) == 3 + for id_ in created.succeeded: + t = db.get(Thing, id_) + assert t is not None and t.is_deleted + + +def test_bulk_reports_failures_without_aborting(bulk: BulkOps, db: Session, actor: User) -> None: + result = bulk.delete(db, [1, 2, 3], actor=actor) # nothing exists + assert result.succeeded == [] + assert len(result.failed) == 3 + assert all("error" in f for f in result.failed) diff --git a/tests/framework/test_crud.py b/tests/framework/test_crud.py new file mode 100644 index 0000000..a8d392b --- /dev/null +++ b/tests/framework/test_crud.py @@ -0,0 +1,129 @@ +"""CRUDService: create/get/list/update/soft_delete/restore + version snapshot + +audit emission. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import Session + +from tests.framework._fixtures import Thing, ThingCreate, ThingUpdate +from ypms.audit.models import AuditLog +from ypms.framework.crud import CRUDService +from ypms.framework.version_history import VersionHistory +from ypms.users.models import User + + +@pytest.fixture +def crud(db: Session) -> CRUDService: + Thing.__table__.create(bind=db.get_bind(), checkfirst=True) + return CRUDService[Thing, ThingCreate, ThingUpdate](Thing, entity="Thing") + + +@pytest.fixture +def actor(db: Session) -> User: + u = User( + username="tester", + email="tester@example.com", + password_hash="not-a-real-hash", + ) + db.add(u) + db.flush() + return u + + +def test_create_emits_audit_and_snapshot(crud: CRUDService, db: Session, actor: User) -> None: + obj = crud.create(db, ThingCreate(name="widget", color="red"), actor=actor) + assert obj.id is not None + assert obj.version == 1 + + audit = db.query(AuditLog).one() + assert audit.action == "create" + assert audit.entity == "Thing" + assert audit.entity_id == obj.id + assert audit.actor_id == actor.id + assert audit.actor_username == "tester" + assert audit.after is not None + assert audit.after["name"] == "widget" + + snap = db.query(VersionHistory).one() + assert snap.entity == "Thing" + assert snap.entity_id == obj.id + assert snap.version == 1 + + +def test_update_bumps_version_and_emits_before_after(crud: CRUDService, db: Session, actor: User) -> None: + obj = crud.create(db, ThingCreate(name="a"), actor=actor) + obj = crud.update(db, obj.id, ThingUpdate(color="blue"), actor=actor) + assert obj.version == 2 + assert obj.color == "blue" + + update_row = db.query(AuditLog).filter(AuditLog.action == "update").one() + assert update_row.before is not None + assert update_row.after is not None + assert update_row.before["color"] is None + assert update_row.after["color"] == "blue" + + assert db.query(VersionHistory).count() == 2 + + +def test_soft_delete_and_restore(crud: CRUDService, db: Session, actor: User) -> None: + obj = crud.create(db, ThingCreate(name="x"), actor=actor) + crud.soft_delete(db, obj.id, actor=actor) + + with pytest.raises(NoResultFound): + crud.get(db, obj.id) + + ghost = crud.get(db, obj.id, include_deleted=True) + assert ghost.is_deleted + + crud.restore(db, obj.id, actor=actor) + live = crud.get(db, obj.id) + assert not live.is_deleted + + actions = [r.action for r in db.query(AuditLog).order_by(AuditLog.id).all()] + assert actions == ["create", "delete", "restore"] + + +def test_list_excludes_soft_deleted_by_default(crud: CRUDService, db: Session, actor: User) -> None: + a = crud.create(db, ThingCreate(name="a"), actor=actor) + b = crud.create(db, ThingCreate(name="b"), actor=actor) + crud.soft_delete(db, a.id, actor=actor) + + page = crud.list(db, limit=10) + assert [t.id for t in page.items] == [b.id] + + with_deleted = crud.list(db, limit=10, include_deleted=True) + assert {t.id for t in with_deleted.items} == {a.id, b.id} + + +def test_list_pagination_forwards_cursor(crud: CRUDService, db: Session, actor: User) -> None: + ids = [crud.create(db, ThingCreate(name=f"n{i}"), actor=actor).id for i in range(5)] + + page1 = crud.list(db, limit=2) + assert [t.id for t in page1.items] == ids[:2] + assert page1.next_cursor == ids[1] + + page2 = crud.list(db, limit=2, cursor=page1.next_cursor) + assert [t.id for t in page2.items] == ids[2:4] + assert page2.next_cursor == ids[3] + + page3 = crud.list(db, limit=2, cursor=page2.next_cursor) + assert [t.id for t in page3.items] == ids[4:] + assert page3.next_cursor is None + + +def test_get_missing_raises(crud: CRUDService, db: Session) -> None: + with pytest.raises(NoResultFound): + crud.get(db, 999) + + +def test_history_returns_snapshots_in_order(crud: CRUDService, db: Session, actor: User) -> None: + obj = crud.create(db, ThingCreate(name="a"), actor=actor) + crud.update(db, obj.id, ThingUpdate(name="b"), actor=actor) + crud.update(db, obj.id, ThingUpdate(name="c"), actor=actor) + + versions = crud.history(db, obj.id) + assert [v.version for v in versions] == [1, 2, 3] + assert [v.snapshot["name"] for v in versions] == ["a", "b", "c"] diff --git a/tests/framework/test_pagination.py b/tests/framework/test_pagination.py new file mode 100644 index 0000000..f3b15cb --- /dev/null +++ b/tests/framework/test_pagination.py @@ -0,0 +1,17 @@ +"""Page serialization.""" + +from __future__ import annotations + +from ypms.framework.pagination import Page + + +def test_to_dict_uses_provided_serializer() -> None: + p: Page[int] = Page(items=[1, 2, 3], next_cursor=42) + out = p.to_dict(lambda i: {"n": i * 2}) + assert out == {"items": [{"n": 2}, {"n": 4}, {"n": 6}], "next_cursor": 42, "total": None} + + +def test_to_dict_carries_total_when_set() -> None: + p: Page[str] = Page(items=["a"], next_cursor=None, total=100) + out = p.to_dict(lambda s: s) + assert out == {"items": ["a"], "next_cursor": None, "total": 100} diff --git a/tests/framework/test_pii_audit.py b/tests/framework/test_pii_audit.py new file mode 100644 index 0000000..6258ca1 --- /dev/null +++ b/tests/framework/test_pii_audit.py @@ -0,0 +1,35 @@ +"""PII-read audit emission (YPMS-specific framework addition).""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ypms.audit.models import AuditLog +from ypms.framework.pii_audit import PII_READ_ACTION, emit_pii_read +from ypms.users.models import User + + +def test_emit_pii_read_records_expected_row(db: Session) -> None: + actor = User(username="reader", email="r@example.com", password_hash="x") + db.add(actor) + db.flush() + + emit_pii_read(db, actor=actor, entity="Registrant", entity_id=42, ip="10.0.0.1") + + row = db.query(AuditLog).one() + assert row.action == PII_READ_ACTION + assert row.entity == "Registrant" + assert row.entity_id == 42 + assert row.actor_id == actor.id + assert row.actor_username == "reader" + assert row.ip == "10.0.0.1" + assert row.before is None + assert row.after is None + + +def test_emit_pii_read_allows_anonymous(db: Session) -> None: + emit_pii_read(db, actor=None, entity="Registrant", entity_id=99) + row = db.query(AuditLog).one() + assert row.actor_id is None + assert row.actor_username is None + assert row.entity_id == 99 diff --git a/tests/security/__init__.py b/tests/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/security/test_rbac.py b/tests/security/test_rbac.py new file mode 100644 index 0000000..9b80917 --- /dev/null +++ b/tests/security/test_rbac.py @@ -0,0 +1,72 @@ +"""RBAC engine: can() decisions + DOES_SYSTEM_ADMIN bypass.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ypms.security.models import Permission +from ypms.security.rbac import ADMIN_ROLE, can +from ypms.users.models import Role, User, UserRole + + +def _mkuser_with_role(db: Session, *, username: str, role_code: str) -> User: + role = Role(code=role_code, description=role_code) + db.add(role) + db.flush() + user = User(username=username, email=f"{username}@example.com", password_hash="x") + db.add(user) + db.flush() + db.add(UserRole(user_id=user.id, role_id=role.id)) + db.flush() + db.refresh(user) + return user + + +def test_admin_role_bypasses_all_checks(db: Session) -> None: + admin = _mkuser_with_role(db, username="admin", role_code=ADMIN_ROLE) + # No permission rows exist; admin still passes. + assert can(db, admin, action="Create", entity="Registrant") is True + assert can(db, admin, action="Delete", entity="Anything") is True + + +def test_user_with_no_roles_is_denied(db: Session) -> None: + user = User(username="orphan", email="orphan@example.com", password_hash="x") + db.add(user) + db.flush() + db.refresh(user) + assert can(db, user, action="View", entity="Registrant") is False + + +def test_permission_row_grants_specific_action(db: Session) -> None: + user = _mkuser_with_role(db, username="staff", role_code="OYP_STAFF") + role_id = user.roles[0].id + db.add(Permission(role_id=role_id, entity="Registrant", action="View")) + db.flush() + + assert can(db, user, action="View", entity="Registrant") is True + # Different action on same entity: denied. + assert can(db, user, action="Delete", entity="Registrant") is False + # Same action on different entity: denied. + assert can(db, user, action="View", entity="Host") is False + + +def test_multiple_roles_union_permissions(db: Session) -> None: + role_a = Role(code="ROLE_A") + role_b = Role(code="ROLE_B") + db.add_all([role_a, role_b]) + db.flush() + + user = User(username="multi", email="m@example.com", password_hash="x") + db.add(user) + db.flush() + db.add_all( + [UserRole(user_id=user.id, role_id=role_a.id), UserRole(user_id=user.id, role_id=role_b.id)] + ) + db.add(Permission(role_id=role_a.id, entity="Registrant", action="View")) + db.add(Permission(role_id=role_b.id, entity="Registrant", action="Edit")) + db.flush() + db.refresh(user) + + assert can(db, user, action="View", entity="Registrant") is True + assert can(db, user, action="Edit", entity="Registrant") is True + assert can(db, user, action="Delete", entity="Registrant") is False diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 0000000..bd6b65e --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,29 @@ +"""Sensitive-field sanitizer strips known key names from log payloads.""" + +from __future__ import annotations + +from ypms.logging import sanitize_for_log + + +def test_top_level_sensitive_keys_are_redacted() -> None: + out = sanitize_for_log({"username": "chris", "password": "hunter2", "ssn": "111-22-3333"}) + assert out == {"username": "chris", "password": "REDACTED", "ssn": "REDACTED"} + + +def test_nested_dicts_are_recursed() -> None: + out = sanitize_for_log( + {"outer": {"token": "xyz", "safe": 1}, "safe_top": True, "authorization": "Bearer x"} + ) + assert out == { + "outer": {"token": "REDACTED", "safe": 1}, + "safe_top": True, + "authorization": "REDACTED", + } + + +def test_lists_are_left_alone() -> None: + # Design decision: no per-element recursion into lists. Callers that + # log lists of potentially-sensitive dicts should map through sanitize + # themselves. Keeps the helper's contract predictable. + out = sanitize_for_log({"users": [{"password": "x"}], "count": 1}) + assert out == {"users": [{"password": "x"}], "count": 1} diff --git a/tests/test_readyz.py b/tests/test_readyz.py new file mode 100644 index 0000000..1eed83f --- /dev/null +++ b/tests/test_readyz.py @@ -0,0 +1,11 @@ +"""/readyz responds 200 when the (SQLite in tests) DB is reachable.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_readyz_returns_ok_when_db_reachable(client: TestClient) -> None: + r = client.get("/readyz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"}