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:
1 parent
0050ad129e
commit
913c69cc5e
8 files changed
+543
No files matched your search
Whitespace-only changes.
@@ -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
|
||||
Reference in new issue
Block a user