Phase 0 milestone 0.4: RBAC seed with 8 roles + permissions matrix

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>
This commit is contained in:
Chris TonjesandClaude Opus 4.7 committed 2026-09-30 19:12:25 -04:00
1 parent 0050ad129e
commit 913c69cc5e
8 files changed
+543

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
"""Idempotent seed subsystem.
Loads roles, users (a bootstrap admin only in Phase 0), and the permissions
matrix into the DB. Runs at container start (see `docker-compose.yml`) and
can be invoked manually as `python -m ypms.seed.run`. Every seed function is
idempotent — running twice must not create duplicates.
"""
+85
View File
@@ -0,0 +1,85 @@
"""Permissions matrix loader.
Loads `permissions_matrix.json` and upserts one `Permission` row per
(role, entity, action) grant. Idempotent — running twice makes no changes.
Deviations from OWC's approach:
- YPMS uses a role-first `grants` map (nested per role → entity → actions)
rather than OWC's action-vs-entity cell matrix. Reason: OWC's ARKCASE
workbook defined actions × entities as a matrix and role scoping was
deferred. YPMS's 8 roles have very different scopes and the role-first
format keeps the JSON human-editable.
- `DOES_SYSTEM_ADMIN` is not present in `grants` — it bypasses via
`security.rbac.ADMIN_ROLE` and does not need permission rows.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from ypms.security.models import Permission
from ypms.users.models import Role
MATRIX_PATH = Path(__file__).parent / "permissions_matrix.json"
def load_matrix() -> dict[str, Any]:
if not MATRIX_PATH.exists():
return {"actions": [], "entities": [], "grants": {}, "roles": []}
data: dict[str, Any] = json.loads(MATRIX_PATH.read_text())
return data
def seed_permissions(db: Session, roles_by_code: dict[str, Role]) -> int:
"""Upsert permission rows from the JSON matrix. Returns rows added."""
matrix = load_matrix()
allowed_actions: set[str] = set(matrix.get("actions", []))
allowed_entities: set[str] = set(matrix.get("entities", []))
grants: dict[str, dict[str, list[str]]] = matrix.get("grants", {})
existing = {
(p.role_id, p.entity, p.action): p
for p in db.query(Permission).all()
}
added = 0
for role_code, per_entity in grants.items():
role = roles_by_code.get(role_code)
if role is None:
# Unknown role in the matrix — surfaced at seed time, not later.
raise ValueError(
f"permissions_matrix.json references unknown role '{role_code}'. "
"Add it to seed.roles.ROLES or remove the grants block."
)
for entity, actions in per_entity.items():
if entity not in allowed_entities:
raise ValueError(
f"permissions_matrix.json role '{role_code}' grants "
f"entity '{entity}' not in the top-level `entities` list."
)
for action in actions:
if action not in allowed_actions:
raise ValueError(
f"permissions_matrix.json role '{role_code}' grants "
f"action '{action}' on '{entity}' not in the "
f"top-level `actions` list."
)
key = (role.id, entity, action)
if key in existing:
continue
db.add(
Permission(
role_id=role.id,
entity=entity,
action=action,
level="C",
)
)
added += 1
db.flush()
return added
+143
View File
@@ -0,0 +1,143 @@
{
"$comment": "YPMS Phase 0 permissions matrix. Loaded at startup by ypms.seed.permissions. DOES_SYSTEM_ADMIN is the super-role and does NOT appear in `grants` — it bypasses every check (see security.rbac.ADMIN_ROLE). Row-level scoping (e.g. HOST_USER's edits limited to their own Host) is enforced at the service layer, not by this matrix; Phase 0 RBAC is action×entity only. See ARCHITECTURE.md §4.2, §8.2.",
"version": "0.1",
"generated_by": "hand-authored 2026-09-30; regenerate via `python -m ypms.seed.run` (idempotent)",
"actions": [
"Create",
"View",
"Edit",
"Delete",
"List",
"Search",
"Restore",
"VersionHistory"
],
"entities": [
"Registrant",
"Guardian",
"ConsentRecord",
"Host",
"HostContact",
"Worksite",
"Position",
"JobPosting",
"Program",
"Cohort",
"EligibilityRule",
"Application",
"EligibilityDecision",
"Certification",
"Orientation",
"Enrollment",
"TimeEntry",
"Timesheet",
"PayrollBatch",
"TaxDocument",
"TransportationVoucher",
"Attachment",
"Note",
"IntegrationEvent",
"Notification",
"EducationRecord",
"FosterIndicator",
"AuditLog",
"User",
"Role"
],
"grants": {
"OYP_STAFF": {
"Registrant": ["Create", "View", "Edit", "List", "Search", "VersionHistory"],
"Guardian": ["Create", "View", "Edit", "List", "Search"],
"ConsentRecord": ["View", "List", "Search"],
"Host": ["Create", "View", "Edit", "List", "Search"],
"HostContact": ["Create", "View", "Edit", "List", "Search"],
"Worksite": ["Create", "View", "Edit", "List", "Search"],
"Position": ["Create", "View", "Edit", "List", "Search"],
"JobPosting": ["Create", "View", "Edit", "Delete", "List", "Search"],
"Program": ["View", "List", "Search"],
"Cohort": ["View", "List", "Search"],
"Application": ["View", "Edit", "List", "Search", "VersionHistory"],
"Certification": ["Create", "View", "Edit", "List", "Search"],
"Orientation": ["View", "List", "Search"],
"Enrollment": ["Create", "View", "Edit", "List", "Search"],
"TimeEntry": ["View", "List", "Search"],
"Timesheet": ["View", "List", "Search"],
"TransportationVoucher": ["View", "List", "Search"],
"Attachment": ["Create", "View", "List", "Search"],
"Note": ["Create", "View", "Edit", "List", "Search"],
"Notification": ["View", "List", "Search"],
"AuditLog": ["View", "List", "Search"]
},
"ELIGIBILITY_EXAMINER": {
"Registrant": ["View", "Edit", "List", "Search", "VersionHistory"],
"Guardian": ["View", "List", "Search"],
"ConsentRecord": ["View", "List", "Search"],
"Application": ["View", "Edit", "List", "Search", "VersionHistory"],
"EligibilityRule": ["View", "List", "Search"],
"EligibilityDecision": ["Create", "View", "Edit", "List", "Search"],
"Certification": ["Create", "View", "Edit", "List", "Search"],
"EducationRecord": ["View", "List", "Search"],
"FosterIndicator": ["View", "List", "Search"],
"Program": ["View", "List", "Search"],
"Cohort": ["View", "List", "Search"],
"Enrollment": ["View", "List", "Search"],
"Note": ["Create", "View", "List", "Search"],
"AuditLog": ["View", "List", "Search"]
},
"PAYROLL_ADMIN": {
"Registrant": ["View", "List", "Search"],
"Enrollment": ["View", "List", "Search"],
"TimeEntry": ["View", "Edit", "List", "Search"],
"Timesheet": ["View", "Edit", "List", "Search"],
"PayrollBatch": ["Create", "View", "Edit", "List", "Search", "VersionHistory"],
"TaxDocument": ["View", "List", "Search"],
"TransportationVoucher": ["Create", "View", "Edit", "List", "Search"],
"Note": ["Create", "View", "List", "Search"],
"AuditLog": ["View", "List", "Search"]
},
"REPORTING_ANALYST": {
"Registrant": ["View", "List", "Search"],
"Application": ["View", "List", "Search"],
"Program": ["View", "List", "Search"],
"Cohort": ["View", "List", "Search"],
"Enrollment": ["View", "List", "Search"],
"Host": ["View", "List", "Search"],
"Worksite": ["View", "List", "Search"],
"Position": ["View", "List", "Search"],
"JobPosting": ["View", "List", "Search"],
"Certification": ["View", "List", "Search"],
"Orientation": ["View", "List", "Search"],
"TimeEntry": ["View", "List", "Search"],
"Timesheet": ["View", "List", "Search"],
"AuditLog": ["View", "List", "Search"]
},
"HOST_USER": {
"Host": ["View", "Edit"],
"HostContact": ["View", "Edit"],
"Worksite": ["Create", "View", "Edit", "List"],
"Position": ["Create", "View", "Edit", "Delete", "List"],
"JobPosting": ["Create", "View", "Edit", "Delete", "List"],
"Enrollment": ["View", "List"],
"TimeEntry": ["View", "Edit", "List"],
"Timesheet": ["View", "Edit", "List"],
"Notification": ["View", "List"]
},
"PARENT_USER": {
"Registrant": ["View", "Edit"],
"Guardian": ["View", "Edit"],
"ConsentRecord": ["Create", "View", "List"],
"Application": ["View", "List"],
"Enrollment": ["View", "List"],
"Notification": ["View", "List"]
},
"YOUTH_USER": {
"Registrant": ["View", "Edit"],
"Application": ["Create", "View", "Edit", "List"],
"Enrollment": ["View", "List"],
"JobPosting": ["View", "List", "Search"],
"Position": ["View", "List", "Search"],
"Attachment": ["Create", "View", "List"],
"Notification": ["View", "List"]
}
}
}
+44
View File
@@ -0,0 +1,44 @@
"""The 8 initial Phase 0 roles.
Refinements land as seed updates, not code changes (see ARCHITECTURE.md §4.2).
Role catalog will grow as domain phases add specialised roles (hearing officer,
BPC clerk, etc.); those additions are seed edits, and RBAC decisions flow
without redeploy.
"""
from __future__ import annotations
ROLES: list[dict[str, str]] = [
{
"code": "DOES_SYSTEM_ADMIN",
"description": "Super-role. Bypasses RBAC per rbac.ADMIN_ROLE. Reserved for the DOES CIO / ISSO and a small named list.",
},
{
"code": "OYP_STAFF",
"description": "Office of Youth Programs staff. Manages registrants, applications, hosts, and worksites across all OYP programs.",
},
{
"code": "ELIGIBILITY_EXAMINER",
"description": "OYP staff certified to make eligibility determinations. Only role beyond DOES_SYSTEM_ADMIN with access to CFSA foster/ward flags and OSSE FERPA records.",
},
{
"code": "PAYROLL_ADMIN",
"description": "Approves payroll batches, views banking fields for direct-deposit, manages transportation vouchers.",
},
{
"code": "REPORTING_ANALYST",
"description": "Read-only across non-sensitive program entities. No banking, no CFSA, no OSSE-sourced records.",
},
{
"code": "HOST_USER",
"description": "External portal user acting as a host/employer. Manages their own Host record, worksites, positions, and postings; sees enrollments assigned to their worksites.",
},
{
"code": "PARENT_USER",
"description": "External portal user acting as parent/guardian of an under-18 registrant. Views and (limited) edits their child's record; owns consent lifecycle.",
},
{
"code": "YOUTH_USER",
"description": "External portal user acting as the registrant themselves. Manages own profile, applications, and enrollment views; browses job postings.",
},
]
+93
View File
@@ -0,0 +1,93 @@
"""Idempotent seed runner. Invoked at container start; also runnable manually.
python -m ypms.seed.run
Seeds:
1. The 8 Phase 0 roles from `roles.ROLES`.
2. A bootstrap admin user (`chris` / password from env `SEED_ADMIN_PASSWORD`,
default `change-me-immediately`) with the `DOES_SYSTEM_ADMIN` role.
3. The permissions matrix from `permissions_matrix.json`.
All three are idempotent. Missing rows are inserted; existing rows are left
alone. Rows removed from the seed sources are NOT deleted from the DB — an
admin removes revoked permissions via the admin UI when it lands (Phase 8+)
or by a manual SQL diff before then.
"""
from __future__ import annotations
import os
import sys
from sqlalchemy.orm import Session
from ypms.auth.security import hash_password
from ypms.db import session_factory
from ypms.logging import configure_logging, get_logger
from ypms.seed.permissions import seed_permissions
from ypms.seed.roles import ROLES
from ypms.users.models import Role, User, UserRole
def seed_roles(db: Session) -> dict[str, Role]:
existing = {r.code: r for r in db.query(Role).all()}
for spec in ROLES:
code = spec["code"]
if code in existing:
# Description drift: update but keep the row.
existing[code].description = spec.get("description")
continue
role = Role(code=code, description=spec.get("description"))
db.add(role)
existing[code] = role
db.flush()
return existing
def seed_bootstrap_admin(db: Session, roles: dict[str, Role]) -> User:
"""Seed a single admin so a fresh container is usable. Non-idempotency
protection: only creates if `chris` doesn't already exist. Password is
forced to be changed on first login only once a UI ships in Phase 1+;
Phase 0 relies on the operator changing it manually via /auth/change-password.
"""
username = "chris"
admin = db.query(User).filter(User.username == username).one_or_none()
if admin:
return admin
password = os.environ.get("SEED_ADMIN_PASSWORD", "change-me-immediately")
admin = User(
username=username,
email="ctonjes@gmail.com",
full_name="Chris Tonjes",
password_hash=hash_password(password),
is_active=True,
assurance_level="admin",
)
db.add(admin)
db.flush()
admin_role = roles["DOES_SYSTEM_ADMIN"]
db.add(UserRole(user_id=admin.id, role_id=admin_role.id))
db.flush()
return admin
def main() -> int:
configure_logging()
log = get_logger(__name__)
SessionLocal = session_factory()
with SessionLocal() as db:
roles = seed_roles(db)
admin = seed_bootstrap_admin(db, roles)
permissions_added = seed_permissions(db, roles)
db.commit()
log.info(
"seed.complete",
roles=len(roles),
admin_username=admin.username,
permissions_added=permissions_added,
)
return 0
if __name__ == "__main__":
sys.exit(main())