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
+6
-4
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
"""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",
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
+29
-3
@@ -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
|
||||||
|
|||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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"}
|
||||||
Reference in new issue
Block a user