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:
1 parent
09d862bf4a
commit
ba756586aa
31 files changed
+1493
-11
No files matched your search
@@ -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.
|
||||
"""
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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"},
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Whitespace-only changes.
@@ -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)
|
||||
Reference in new issue
Block a user