Only successful logins wrote an audit row. Every unsuccessful attempt now
writes `login_failed` with reason bad_credentials, unknown_user,
inactive, or throttled, plus the client IP. The attempted username is
recorded only when it matches a real account (as the actor); unknown-user
attempts store no username, since people sometimes type a password into
that field.
Found while preparing Phase 0 control evidence (milestone 0.9).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Before flipping control-mapping rows, wire the Phase 0 promises that had
no code behind them yet:
- SI-11 / C.5.7.43: app-wide exception handler returns
{"error":"internal_error","request_id":...} and the X-Request-ID header.
Logs exception type + stack frames, not the message — driver errors
echo bound parameters and key values. Engine set to hide_parameters.
- AU-11 / SI-12 (plan §7 risk): create_app refuses to start if any
mapped table lacks a retention policy.
- CM-3: migrations/IMMUTABLE.sha256 pins every merged revision; a test
fails on any edit or on an unpinned new revision.
- migrations/env.py: fileConfig(disable_existing_loggers=False) so
in-process Alembic (the drift test) doesn't silence app loggers.
163 tests passing; ruff, mypy, bandit, gitleaks clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHASE0_PLAN 0.8 marked in progress with the remaining exit check;
ARCHITECTURE 9.3 and the plan's CI-provider question resolved to Gitea
Actions; YPMS-POAM-042 to in progress; status handoff gets 0.8 details,
runner-registration steps, local gate commands, and the accumulated
upstream-to-OWC list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Workflow moved to .gitea/workflows/ci.yml (Gitea is the CI host). Every
gate now passes locally, step-for-step; green on main is pending an
act_runner on gitea.doesworks.net (runs #1-#5 are queued "Waiting").
Gate fixes found by actually running them:
- pip-audit: `--disable-pip` without `-r` was a usage error, so the old
step could never pass. Now `pip-audit --strict .` audits the runtime
dependency set from pyproject.
- pip-audit then flagged ecdsa (PYSEC-2026-1325, no fix), a hard dep of
python-jose. Swapped to PyJWT (HS256 only; 3-line change in
auth/security.py). Flag to OWC.
- PyJWT warned the test JWT secret was 11 bytes. JWT_SECRET now requires
>= 32 bytes (RFC 7518 s3.2) at settings load; dev/compose/test defaults
updated. Test-only value carries an inline gitleaks:allow.
- bandit B101: assert in retention._count_past replaced with a raise.
- gitleaks: gitleaks-action needs a GitHub token/license; replaced with
the pinned 8.30.1 binary, sha256-verified, scanning full history.
Added:
- SBOM: CycloneDX 1.6 from a clean runtime-only venv (39 components),
uploaded as a CI artifact. YPMS-POAM-042.
- cyclonedx-bom in dev extras; CI outputs gitignored.
158 tests passing, 95.83% coverage; ruff, mypy, bandit, pip-audit,
gitleaks clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Status handoff also records the 0002 unique-constraint drift follow-up
and the CI-host question (Gitea vs. GitHub Actions) blocking 0.8.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every mapped table resolves a retention policy; a report-only job runs
over all of them and logs "N records to purge". ARCH §6.4; NIST AU-11,
SI-12; source RFP C.5.7.42.
- framework/retention.py: RetentionPolicy (data class, disposal, period,
anchor column, cadence, approved, authority), run_retention(), and a
`python -m ypms.framework.retention` entrypoint. Phase 0 never mutates
data: interim-period rows are counted (interim_eligible), approved-period
rows are counted (to_purge), nothing is purged. One retention_run audit
row per run.
- retention_classes.py: PIA §9 data classes at their interim periods, all
approved=False pending YPMS-POAM-054. Classes without a PIA basis
(hosts, operational logs, accounts, consent-derived) are unset and logged
by name with their governing POA&M item. Vendored framework tables are
mapped by table name so vendored files stay unchanged.
- stub_models.py + migration 0004: id + mixin models for the 26 stub
entities so each can carry a policy. Migration uses a literal table list.
- ypms/models.py: single import-all module used by Alembic env, the job,
and the test fixtures.
- Drift guard: test upgrades a scratch SQLite DB to head and compares to
Base.metadata. Found pre-existing drift in 0002 (named unique
constraints duplicating the unique indexes on roles.code, users.username,
users.email); allow-listed, follow-up noted in PHASE0_STATUS.
156 tests passing; ruff + mypy clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Status handoff also records four permission-matrix review items the
stub sweep surfaced (no Restore holders; admin-only Program /
EligibilityRule config; admin-only IntegrationEvent; HOST_USER Delete
pending row-level ownership).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
26 stub entities (every matrix entity except Registrant, User, Role,
AuditLog) get a router from framework/stub_router.py: one route per
matrix action (List, Search, Create, View, Edit, Delete, Restore,
VersionHistory). Each route runs the real RBAC check, writes an audit
row, then returns 501. Source RFP C.5.7.27.
- Registry in src/ypms/stubs.py; test fails if a matrix entity is
neither real nor stubbed.
- tests/test_stubs.py sweeps entity x role x route against the matrix
JSON: 501 + exactly one audit row when granted, 403 + none when not.
FERPA/CFSA stubs pinned closed to non-examiner roles.
- Registrant restore now gated on the matrix's Restore action (was
Edit, the vendored-factory convention); only the super-role holds it.
- Test fixtures (roles, make_user, api) lifted to conftest; one app per
session (route construction dominated runtime) and a shared password
hash for minted-token users.
138 tests passing, ruff + mypy clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PHASE0_STATUS also refreshed: SSH remote, push state, 0.6 as next
milestone, new gotchas (write-only SSN, __audit_redact__, key changes).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Registrant model + schemas + router + migration 0003, exercising the
vendored framework end-to-end (CRUDService, RBAC, audit, version history).
- SSN app-layer encrypted (AES-256-GCM, HKDF key, column-bound AAD,
versioned format) in security/column_crypto.py; write-only on the model;
serializer exposes only ssn_masked (XXX-XX-1234). C.5.7.50 / SC-28.
- pii_read audit on GET, list (one row per page with ids), and history.
C.5.6.9 #5 / AU-14.
- Portal-only callers (YOUTH/PARENT/HOST) limited to own row via
Registrant.user_id; out-of-scope is 404. PARENT_USER sees nothing until
Guardian lands. History gated on VersionHistory, not View.
- minor_flag derived from dob at read time.
Framework changes (flag to OWC on re-vendor):
- crud._to_dict honors __audit_redact__, keeping ciphertext out of
audit_log and version_history.
- crud._to_dict serializes date (not only datetime) for JSON columns.
- CRUDService.history annotation uses builtins.list (method shadowed it).
98 tests passing, 94.83% coverage; ruff + mypy clean.
Coverage trace at docs/coverage/registrant.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Snapshot of where Phase 0 stands after milestones 0.1-0.4. Structured
as: TL;DR, current state, what's done per milestone, what's next, locked
decisions, open questions, repo state, how to run + verify, gotchas,
file map, POA&M state. Mirrors the DCYWP PHASE_2_STATUS pattern so
anyone (or a future session) can pick this up cold.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Data-driven RBAC. The matrix is the source of truth for who can do what;
runtime looks it up via security.rbac.can() (already in place from 0.2).
Refinements land as seed updates, not code changes.
Roles seeded (src/ypms/seed/roles.py):
- DOES_SYSTEM_ADMIN Super-role. Bypasses via ADMIN_ROLE.
- OYP_STAFF General OYP program staff.
- ELIGIBILITY_EXAMINER Certified eligibility staff — only non-admin
role with access to CFSA foster/ward and
OSSE FERPA records.
- PAYROLL_ADMIN Approves payroll batches; banking access.
- REPORTING_ANALYST Read-only across non-sensitive entities.
- HOST_USER External portal: host/employer.
- PARENT_USER External portal: parent/guardian of under-18.
- YOUTH_USER External portal: registrant.
Matrix (src/ypms/seed/permissions_matrix.json):
- Role-first grants map (nested role → entity → actions[]) rather than
OWC's action-vs-entity cell matrix. Reason in permissions.py header:
YPMS's 8 roles have very different scopes.
- 30 entities from ARCH §6.1 declared; 8 canonical actions.
- DOES_SYSTEM_ADMIN deliberately absent from grants — bypasses via the
super-role.
- Row-level scoping ("HOST_USER edits only THEIR host") is service-layer,
not RBAC-layer; Phase 0 is action×entity only, per ARCHITECTURE.md §4.2.
Loader (src/ypms/seed/permissions.py):
- Validates every referenced role / entity / action against the top-level
declared sets — misspelled cells surface at seed time, not later.
- Idempotent: existing (role_id, entity, action) rows are skipped.
- Removals aren't propagated automatically — that's a Phase 8+ admin-UI
responsibility per header note.
Runner (src/ypms/seed/run.py):
- `python -m ypms.seed.run` — idempotent runner. Seeds:
1. Roles from ROLES (updates description drift)
2. Bootstrap admin `chris` with DOES_SYSTEM_ADMIN role
(password from SEED_ADMIN_PASSWORD env, default
change-me-immediately)
3. Permissions matrix
- docker-compose command now chains this after `alembic upgrade head`
so a fresh container is usable.
Tests (53 passing, 92.20% coverage — up from 43 / 93.47%):
- tests/seed/test_seed.py 10 tests covering:
* seed_roles idempotency + description-drift update
* seed_bootstrap_admin creates + is idempotent
* seed_permissions inserts + is idempotent
* matrix declared actions/entities are self-consistent
* matrix grants don't reference unseeded roles
* ADMIN_ROLE NOT in grants (must bypass, not have rows)
* unknown role in matrix raises at seed time
* end-to-end can() decisions per role (~25 assertions):
- OYP_STAFF sees registrants but not FERPA/CFSA/PayrollBatch
- ELIGIBILITY_EXAMINER sees FERPA/CFSA but not banking
- PAYROLL_ADMIN handles PayrollBatch but not FERPA/CFSA
- REPORTING_ANALYST is strictly read-only
- HOST_USER / PARENT_USER / YOUTH_USER can't touch admin surfaces
- DOES_SYSTEM_ADMIN bypasses even sensitive entities
Advances YPMS-POAM-040. Unblocks milestone 0.5 (Registrant vertical slice)
and 0.6 (501-stub routers for the other entities in ARCH §6.1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lands local password auth with JWT access + refresh tokens, and starts
carrying YPMS-specific claims (`uid`, `roles`, `iam.assurance_level`,
`mfa_verified`) so downstream authorization can gate high-risk actions
above base RBAC without a DB round-trip. Follows the OWC auth pattern
verbatim except where noted.
Added:
- src/ypms/auth/security.py hash_password / verify_password
(pbkdf2_sha256), create_access_token
with YPMS-specific claims,
create_refresh_token, decode_token
- src/ypms/auth/rate_limit.py SlidingWindow (vendored from OWC),
login_limiter tuned to NIST AC-7 /
source RFP C.5.7.10: 5 failed attempts
per IP per 15 minutes → 429 lockout
- src/ypms/auth/schemas.py LoginResponse, RefreshRequest,
RefreshResponse, ChangePasswordRequest,
UserRead
- src/ypms/auth/deps.py get_current_user replaces the milestone
0.2 stub; rejects refresh tokens on
resource endpoints; refuses inactive users
- src/ypms/auth/router.py POST /auth/login (OAuth2 form; audit-logs
successful auth; DOES_SYSTEM_ADMIN role
upgrades assurance_level to `admin` in
the token)
POST /auth/refresh (issues fresh access;
refuses non-refresh tokens)
GET /auth/me
POST /auth/change-password (self-service;
≥12 char floor; audit-logged)
- src/ypms/main.py Registers auth_router
Tests (43 passing, 93.47% coverage — up from 26 / 93.10%):
- tests/auth/test_security.py Token claims shape, refresh-vs-access
separation, invalid-token handling
(5 tests)
- tests/auth/test_router.py End-to-end via TestClient with a
dependency-overridden DB: login happy
path + wrong password + inactive user,
admin assurance upgrade, /me auth-gate,
refresh happy path + reject access token,
change-password full flow + length gate,
login lockout after 5 attempts (12 tests)
Test-infra fixes:
- tests/conftest.py Env defaults moved from a session-scoped
fixture to module-import time so
`ypms.main` (which builds the app at
import) can be transitively imported by
test modules without ValidationError on
Settings.
`db` fixture switched to StaticPool +
check_same_thread=False so TestClient's
worker thread sees the same in-memory
SQLite DB the fixture set up.
Notes:
- OWC includes an /auth/register for external users; that's out of scope
here — Phase 1 lands parental co-consent and youth/host registration,
which needs its own PIA-driven flow. Keeping /auth minimal in Phase 0.
- MFA is captured in `User.mfa_verified` and JWT claim but not enforced
yet; enforcement lands as an assurance-level gate in a later phase.
- Refresh flow returns only a new access token; refresh-token rotation
is a later hardening step.
Advances YPMS-POAM-040 and unblocks milestone 0.4 (RBAC seed) and 0.5
(Registrant vertical slice).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>