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

+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