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

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