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

+1
View File
@@ -34,6 +34,7 @@ services:
- "8000:8000"
command: >
sh -c "alembic upgrade head &&
python -m ypms.seed.run &&
uvicorn ypms.main:app --host 0.0.0.0 --port 8000 --reload"
volumes:
- ./src:/app/src
+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())
View File
Whitespace-only changes.
+170
View File
@@ -0,0 +1,170 @@
"""Seed subsystem: roles, admin user, permissions matrix, idempotency."""
from __future__ import annotations
import pytest
from sqlalchemy.orm import Session
from ypms.security.models import Permission
from ypms.security.rbac import ADMIN_ROLE, can
from ypms.seed.permissions import load_matrix, seed_permissions
from ypms.seed.roles import ROLES
from ypms.seed.run import seed_bootstrap_admin, seed_roles
from ypms.users.models import Role, User
def test_seed_roles_creates_all_and_is_idempotent(db: Session) -> None:
roles_first = seed_roles(db)
assert set(roles_first) == {r["code"] for r in ROLES}
roles_second = seed_roles(db)
assert {r.id for r in roles_first.values()} == {r.id for r in roles_second.values()}
def test_seed_roles_updates_description_drift(db: Session) -> None:
seed_roles(db)
orig = db.query(Role).filter(Role.code == "OYP_STAFF").one()
orig.description = "wrong description"
db.flush()
seed_roles(db)
refreshed = db.query(Role).filter(Role.code == "OYP_STAFF").one()
assert refreshed.description != "wrong description"
assert refreshed.description is not None
assert "Office of Youth Programs" in refreshed.description
def test_seed_bootstrap_admin_creates_admin_with_super_role(db: Session) -> None:
roles = seed_roles(db)
admin = seed_bootstrap_admin(db, roles)
assert admin.username == "chris"
assert admin.is_active is True
db.refresh(admin)
assert ADMIN_ROLE in {r.code for r in admin.roles}
def test_seed_bootstrap_admin_is_idempotent(db: Session) -> None:
roles = seed_roles(db)
first = seed_bootstrap_admin(db, roles)
second = seed_bootstrap_admin(db, roles)
assert first.id == second.id
def test_seed_permissions_inserts_rows_from_matrix(db: Session) -> None:
roles = seed_roles(db)
added = seed_permissions(db, roles)
assert added > 0
total = db.query(Permission).count()
assert total == added
def test_seed_permissions_is_idempotent(db: Session) -> None:
roles = seed_roles(db)
first = seed_permissions(db, roles)
second = seed_permissions(db, roles)
assert second == 0
total_after_second_pass = db.query(Permission).count()
assert total_after_second_pass == first
def test_matrix_grants_reference_only_declared_actions_and_entities() -> None:
matrix = load_matrix()
actions = set(matrix["actions"])
entities = set(matrix["entities"])
for role_code, per_entity in matrix["grants"].items():
for entity, action_list in per_entity.items():
assert entity in entities, f"{role_code}: entity '{entity}' not declared"
for a in action_list:
assert a in actions, f"{role_code}: action '{a}' on '{entity}' not declared"
def test_matrix_grants_reference_only_declared_roles() -> None:
matrix = load_matrix()
role_codes_in_seed = {r["code"] for r in ROLES}
for role_code in matrix["grants"]:
assert role_code in role_codes_in_seed, (
f"grants contains '{role_code}' but seed.roles.ROLES does not"
)
# Super-role must NOT appear in grants (it bypasses).
assert ADMIN_ROLE not in matrix["grants"]
def test_unknown_role_in_matrix_raises(db: Session) -> None:
"""Guard: if the JSON references a role the seed doesn't know about, seed
fails loudly rather than silently dropping grants."""
roles = seed_roles(db)
# Simulate a matrix mismatch by passing a subset of roles.
partial = {code: r for code, r in roles.items() if code != "OYP_STAFF"}
with pytest.raises(ValueError, match="unknown role 'OYP_STAFF'"):
seed_permissions(db, partial)
def test_end_to_end_rbac_decisions_per_role(db: Session) -> None:
"""The point of the seed is that can() returns the right answers.
Spot-check each role's most important grants and denials.
"""
roles = seed_roles(db)
seed_permissions(db, roles)
def user_with(role_code: str) -> User:
from ypms.users.models import UserRole
u = User(username=f"u_{role_code.lower()}", password_hash="x")
db.add(u)
db.flush()
db.add(UserRole(user_id=u.id, role_id=roles[role_code].id))
db.flush()
db.refresh(u)
return u
# DOES_SYSTEM_ADMIN bypasses everything.
admin = user_with(ADMIN_ROLE)
assert can(db, admin, "Delete", "FosterIndicator") is True
assert can(db, admin, "Create", "PayrollBatch") is True
# OYP_STAFF: general grants, but NOT FERPA/CFSA and NOT PayrollBatch.
oyp = user_with("OYP_STAFF")
assert can(db, oyp, "Create", "Registrant") is True
assert can(db, oyp, "Edit", "Host") is True
assert can(db, oyp, "View", "EducationRecord") is False
assert can(db, oyp, "View", "FosterIndicator") is False
assert can(db, oyp, "Create", "PayrollBatch") is False
# ELIGIBILITY_EXAMINER: sees FERPA/CFSA; does NOT edit Host or PayrollBatch.
exm = user_with("ELIGIBILITY_EXAMINER")
assert can(db, exm, "View", "EducationRecord") is True
assert can(db, exm, "View", "FosterIndicator") is True
assert can(db, exm, "Create", "EligibilityDecision") is True
assert can(db, exm, "Edit", "Host") is False
assert can(db, exm, "Create", "PayrollBatch") is False
# PAYROLL_ADMIN: banking + payroll batch; NOT FERPA/CFSA.
pay = user_with("PAYROLL_ADMIN")
assert can(db, pay, "Create", "PayrollBatch") is True
assert can(db, pay, "Edit", "Timesheet") is True
assert can(db, pay, "View", "FosterIndicator") is False
assert can(db, pay, "Edit", "Registrant") is False
# REPORTING_ANALYST: strictly read-only; nothing sensitive.
rep = user_with("REPORTING_ANALYST")
assert can(db, rep, "View", "Application") is True
assert can(db, rep, "Create", "Application") is False
assert can(db, rep, "View", "FosterIndicator") is False
assert can(db, rep, "View", "PayrollBatch") is False
# External-portal roles never touch program admin surfaces.
host = user_with("HOST_USER")
assert can(db, host, "Edit", "Host") is True
assert can(db, host, "View", "Registrant") is False
assert can(db, host, "View", "FosterIndicator") is False
parent = user_with("PARENT_USER")
assert can(db, parent, "Create", "ConsentRecord") is True
assert can(db, parent, "View", "Registrant") is True
assert can(db, parent, "Edit", "Host") is False
assert can(db, parent, "View", "PayrollBatch") is False
youth = user_with("YOUTH_USER")
assert can(db, youth, "Create", "Application") is True
assert can(db, youth, "View", "JobPosting") is True
assert can(db, youth, "View", "PayrollBatch") is False
assert can(db, youth, "View", "FosterIndicator") is False