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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
]
|
||||
@@ -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())
|
||||
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