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
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.
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",
]
+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)