Phase 0 milestone 0.2: vendor OWC framework + YPMS pii_audit

Brings in a working generic-CRUD framework plus its dependencies from OWC
(../owc-system/src/owc/, snapshot 2026-04-20). Every vendored module carries
a header comment naming the source; the OWC → ypms rename is the only
mechanical change.

Vendored modules:

- framework/base.py           TimestampMixin, SoftDeleteMixin, VersionedMixin
- framework/pagination.py     Page[T] with to_dict()
- framework/version_history.py  Snapshot table
- framework/crud.py           CRUDService with audit emission
- framework/bulk.py           BulkOps (BulkResult / BulkUpdateItem)
- framework/router.py         make_router() factory (kept without
                              `from __future__ import annotations` for
                              FastAPI type introspection — see the module
                              docstring)
- audit/models.py             AuditLog
- audit/service.py            record()
- security/models.py          Permission
- security/rbac.py            can() + require_permission() dependency
                              (DOES_SYSTEM_ADMIN as the super-role)
- users/models.py             User, Role, UserRole (YPMS adds
                              mfa_verified and assurance_level; drops
                              OWC-specific claimant_id)
- auth/deps.py                get_current_user() placeholder — real JWT
                              verify lands in 0.3

YPMS-specific additions on top of the vendored framework:

- framework/pii_audit.py      emit_pii_read() — NIST AU-14 / source RFP
                              C.5.6.9 #5. OWC doesn't need this because
                              its case-management surface is staff-only;
                              YPMS must audit reads of minors' PII, SSN,
                              banking, FERPA-covered OSSE records, and
                              CFSA foster/ward flags.

Bugfix vs. upstream OWC:

- framework/crud.py           Pagination `next_cursor` was previously the
                              peek-ahead row's id, which combined with
                              `where id > cursor` on the next page
                              silently dropped one row per boundary.
                              Caught by test_list_pagination_forwards_cursor.
                              Fixed here; noted in the module header so
                              re-vendoring OWC surfaces it upstream.

Migrations:

- 0002_framework_tables.py    Baseline for users, roles, user_roles,
                              permissions, audit_log, version_history.
                              Immutable once merged per CONTRIBUTING.md.
- migrations/env.py           Imports the new model modules so
                              autogenerate compares against them.

Tests (26 passing, 93.10% coverage, floor 80%):

- tests/framework/test_crud.py         CRUDService lifecycle + snapshots
                                       + audit emission (7 tests)
- tests/framework/test_bulk.py         BulkOps happy paths + failure
                                       reporting (4 tests)
- tests/framework/test_pagination.py   Page.to_dict serializer contract
                                       (2 tests)
- tests/framework/test_pii_audit.py    emit_pii_read row shape,
                                       anonymous case (2 tests)
- tests/security/test_rbac.py          can() + admin bypass + role union
                                       (4 tests)
- tests/test_readyz.py                 /readyz smoke (1 test)
- tests/test_logging.py                sanitize_for_log contract (3 tests)

Green locally: ruff clean, mypy clean (with a targeted override on
framework/router.py and framework/crud.py for TypeVar limitations that
OWC also accepts).

Advances YPMS-POAM-040. Prepares milestone 0.3 (JWT auth) and 0.4 (RBAC
seed) to plug into an already-live framework.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris TonjesandClaude Opus 4.7 committed 2026-09-30 16:55:56 -04:00
1 parent 09d862bf4a
commit ba756586aa
31 files changed
+1493 -11

No files matched your search

+6 -4
View File
@@ -1,8 +1,7 @@
"""Alembic environment. """Alembic environment.
At milestone 0.1 the metadata is empty (no models yet). Milestones 0.5 and 0.6 Model modules are imported here so `Base.metadata` is populated before Alembic
register real and stub entity models, at which point `target_metadata` will compares against it. Add every new model module to the import block below.
carry their tables. `alembic upgrade head` currently applies a no-op baseline.
""" """
from __future__ import annotations from __future__ import annotations
@@ -16,7 +15,10 @@ from ypms.db import Base
from ypms.settings import get_settings from ypms.settings import get_settings
# ---- Import model modules here so Base.metadata is populated ---- # ---- 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 config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -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)
+22 -1
View File
@@ -45,7 +45,13 @@ addopts = "-ra --strict-markers"
[tool.coverage.run] [tool.coverage.run]
source = ["src/ypms"] 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] [tool.coverage.report]
show_missing = true show_missing = true
@@ -65,5 +71,20 @@ python_version = "3.12"
strict = true strict = true
ignore_missing_imports = 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] [tool.bandit]
exclude_dirs = ["tests", "migrations"] exclude_dirs = ["tests", "migrations"]
+6
View File
@@ -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.
"""
+36
View File
@@ -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))
+40
View File
@@ -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
View File
Whitespace-only changes.
+28
View File
@@ -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"},
)
+39 -3
View File
@@ -1,6 +1,42 @@
"""YPMS framework package. """YPMS framework package.
Phase 0 milestone 0.2 will vendor in OWC's `framework/` modules (base entity, Vendored from OWC (`../owc-system/src/owc/framework/`, snapshot 2026-04-20)
mixins, CRUDService, router factory, audit emitter, pagination) and add YPMS's with `owc` → `ypms` renames and YPMS-specific additions. Keep vendored files
`pii_audit.py` decorator on top. This package is empty at milestone 0.1. 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",
]
+45
View File
@@ -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}
+63
View File
@@ -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
+166
View File
@@ -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())
+24
View File
@@ -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,
}
+52
View File
@@ -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,
)
+214
View File
@@ -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}"
+29
View File
@@ -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)
+23
View File
@@ -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)
+60
View File
@@ -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
View File
Whitespace-only changes.
+68
View File
@@ -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)
+29 -3
View File
@@ -1,8 +1,8 @@
"""Shared pytest fixtures. """Shared pytest fixtures.
Uses SQLite for speed (see ARCHITECTURE.md §4.8). Any test that needs Uses SQLite for speed (see ARCHITECTURE.md §4.8). Tests exercise the framework
Postgres-specific behaviour lives in a Postgres-only fixture path (added when a against an in-memory Postgres-shaped SQLAlchemy Base — no Alembic migrations
migration or query needs it). in the test path; models create their tables from metadata.
""" """
from __future__ import annotations from __future__ import annotations
@@ -12,6 +12,8 @@ from collections.abc import Iterator
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
@@ -26,6 +28,30 @@ def _test_env() -> Iterator[None]:
yield 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 @pytest.fixture
def client() -> Iterator[TestClient]: def client() -> Iterator[TestClient]:
from ypms.main import create_app from ypms.main import create_app
View File
Whitespace-only changes.
+38
View File
@@ -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
+69
View File
@@ -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)
+129
View File
@@ -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"]
+17
View File
@@ -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}
+35
View File
@@ -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
View File
Whitespace-only changes.
+72
View File
@@ -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
+29
View File
@@ -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}
+11
View File
@@ -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"}