From 913c69cc5ede2b258470ef4ffccacc1f2291cc43 Mon Sep 17 00:00:00 2001 From: Chris Tonjes Date: Wed, 30 Sep 2026 19:12:25 -0400 Subject: [PATCH] Phase 0 milestone 0.4: RBAC seed with 8 roles + permissions matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docker-compose.yml | 1 + src/ypms/seed/__init__.py | 7 ++ src/ypms/seed/permissions.py | 85 +++++++++++++ src/ypms/seed/permissions_matrix.json | 143 ++++++++++++++++++++++ src/ypms/seed/roles.py | 44 +++++++ src/ypms/seed/run.py | 93 ++++++++++++++ tests/seed/__init__.py | 0 tests/seed/test_seed.py | 170 ++++++++++++++++++++++++++ 8 files changed, 543 insertions(+) create mode 100644 src/ypms/seed/__init__.py create mode 100644 src/ypms/seed/permissions.py create mode 100644 src/ypms/seed/permissions_matrix.json create mode 100644 src/ypms/seed/roles.py create mode 100644 src/ypms/seed/run.py create mode 100644 tests/seed/__init__.py create mode 100644 tests/seed/test_seed.py diff --git a/docker-compose.yml b/docker-compose.yml index d56da71..211a289 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/src/ypms/seed/__init__.py b/src/ypms/seed/__init__.py new file mode 100644 index 0000000..ba48292 --- /dev/null +++ b/src/ypms/seed/__init__.py @@ -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. +""" diff --git a/src/ypms/seed/permissions.py b/src/ypms/seed/permissions.py new file mode 100644 index 0000000..7068184 --- /dev/null +++ b/src/ypms/seed/permissions.py @@ -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 diff --git a/src/ypms/seed/permissions_matrix.json b/src/ypms/seed/permissions_matrix.json new file mode 100644 index 0000000..02b4ffd --- /dev/null +++ b/src/ypms/seed/permissions_matrix.json @@ -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"] + } + } +} diff --git a/src/ypms/seed/roles.py b/src/ypms/seed/roles.py new file mode 100644 index 0000000..59a2556 --- /dev/null +++ b/src/ypms/seed/roles.py @@ -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.", + }, +] diff --git a/src/ypms/seed/run.py b/src/ypms/seed/run.py new file mode 100644 index 0000000..be3e7ac --- /dev/null +++ b/src/ypms/seed/run.py @@ -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()) diff --git a/tests/seed/__init__.py b/tests/seed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/seed/test_seed.py b/tests/seed/test_seed.py new file mode 100644 index 0000000..eb8606c --- /dev/null +++ b/tests/seed/test_seed.py @@ -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