14 — QA Checklist (RBAC Module)
- 1. Permission matrix — edge cases
- 2. Roles CRUD & custom-role persistence
- 3. Members & 403 behaviour
- 4. Tenant isolation
- 5. Privilege-escalation scenarios (security focus)
- 6. Wire-contract checks
- 7. Performance & a11y (from 00-shared/10, RBAC-specific)
- 8. Regression hooks (test plan linkage)
Module-specific QA on top of 00-shared/10. Every scenario below traces to a server behaviour (
rbac.service.ts,rbac.guard.ts,role.schema.ts,organization-member.schema.ts) or a wire-contract rule. Cross-ref:PLAN.mdrows 1.5 (PLAN.md:17), 10.1-10.5 (PLAN.md:106-110), 12.x (PLAN.md:127-132), 14.x (PLAN.md:146-148), 19.x (PLAN.md:189-191).
1. Permission matrix — edge cases
| # | Scenario | Expected | Source |
|---|---|---|---|
| M1 | Catalog has exactly 95 perms; matrix renders all 26 groups; count badge = 95 | no truncation, scrollable | permissions.constants.ts:1-97 |
| M2 | Toggle all in group → group checkbox checked; toggle one → indeterminate | tri-state correct | 06 §3 |
| M3 | Search "pay" → only payments/receipts group + perms; 0-match groups collapse | filter correct; liveRegion count | 07 §1-2 |
| M4 | Save with empty permissions → warning shown, POST succeeds (role like student, role.schema.ts:63) | empty allowed | 08 §1 |
| M5 | Permission string not in catalog (client injection attempt) → blocked client-side | whitelist enforced in UI | server @IsArray only, create-role.dto.ts:36 |
| M6 | Role with 95 perms (clone org_admin) → save round-trip < 1.5 s p95 | single PATCH | rbac.service.ts:96 |
| M7 | Rapid toggling while saving → anti-double-submit; state not corrupted | disabled during save | 10 §4 |
| M8 | 95-perm matrix at text scale 2× → no clipping; chips wrap | 00-shared/09 §4 | — |
| M9 | Matrix in dark mode — chip contrast ≥ 4.5:1 | token check | 11 §6 |
2. Roles CRUD & custom-role persistence
| # | Scenario | Expected | Source |
|---|---|---|---|
| R1 | Create role → appears in Roles list under Custom section; persists across app restart (server fetch) | persistence | rbac.service.ts:83-89 |
| R2 | Create with existing slug → 409 inline, no state loss (form preserved) | error mapping | rbac.service.ts:86-87 |
| R3 | Create without forcing isSystem:false (bug path) → server would lock; UI always sends false | regression guard (OQ-R2) | role.schema.ts:78-79 |
| R4 | Edit custom role perms → PATCH 200 → detail refreshed; holders see change ≤ 5 min (cache) | propagation banner | rbac.service.ts:68,96 |
| R5 | System role edit/delete → UI blocks (no editor, no delete); API 400 fallback shown if reached | locked | rbac.service.ts:94-95,104-105 |
| R6 | Delete custom role → soft-deleted; gone from list; members keep stale slug chip (render-only) | rbac.service.ts:106; 09 §2 | |
| R7 | Role slug immutability communicated; PATCH slug would orphan member arrays — UI treats as immutable | 08 §1 | — |
| R8 | Duplicate slug on update → 409 handled identically to create | rbac.service.ts:86-87 (create path only — update path 409 via unique index; OQ-R11) | — |
3. Members & 403 behaviour
| # | Scenario | Expected | Source |
|---|---|---|---|
| B1 | Non-org_admin (e.g. teacher JWT) calls /rbac/roles → 403 PERMISSION_DENIED | envelope; no data | rbac.controller.ts:21, rbac.guard.ts:38-42 |
| B2 | Client routes hidden for teacher; direct deep link → 403 screen with cause copy + requestId | PLAN.md:17 (1.5) | 04 §3, 06 §S6 |
| B3 | Member with perm rbac.member.read but not org_admin → today 403 (OQ-R1) — UI must not promise access | flagged, not silent | rbac.controller.ts:21 |
| B4 | Add duplicate member → client pre-check blocks; if server 500 slips through → conflict banner + refresh (OQ-R5) | no silent error | organization-member.schema.ts:48 |
| B5 | Remove member → row gone; re-fetch confirms isDeleted filtered (base.repository.ts:20-30) | rbac.service.ts:138-140 | — |
| B6 | Update member roles → PATCH 200; chips update; userId sent but ignored (DTO compat) | rbac.controller.ts:71-72 | — |
| B7 | Remove own org_admin → typed confirm; after removal JWT still grants access until expiry — banner explains re-login | 09 §4 | — |
4. Tenant isolation
| # | Scenario | Expected | Source |
|---|---|---|---|
| T1 | Tenant A admin lists roles/members → zero Tenant B docs (repo scoping) | base.repository.ts:20-30; PLAN.md:106 (10.1) | — |
| T2 | Tenant B user uses Tenant A JWT → 401/403 by token claims | jwt-auth.guard.ts:40-55; PLAN.md:107 (10.2) | — |
| T3 | Role slug collision across tenants → both create successfully (unique index is {tenantId, slug}) | role.schema.ts:89 | — |
| T4 | Member {tenantId,userId} unique per tenant — same user in 2 tenants = 2 memberships (supported model, COLLECTIONS.md:554-570) | organization-member.schema.ts:48 | — |
| T5 | Permission cache keys namespaced sl:{tenantId}:perm:{userId} — no cross-tenant cache leak | rbac.service.ts:48; PLAN.md:109 (10.4) | — |
| T6 | platform_admin reads across tenants via repo bypass — UI must not render tenant RBAC data cross-tenant without an org_admin token | base.repository.ts:21; 02 §4 | — |
| T7 | seedDefaults(org.slug) vs seedDefaults(user.tenantId) inconsistency — verify tenantId == slug after org-created flow (OQ-R6) | organizations.service.ts:58, auth.service.ts:93 | — |
5. Privilege-escalation scenarios (security focus)
| # | Attack / scenario | Defence | Source |
|---|---|---|---|
| P1 | Client sets permissions: ['organization.delete'] on own role via PATCH | Server: org_admin-role gate only; once perms are enforced (Phase-5 planned) the guard rejects. Client: matrix whitelist. Today: any org_admin can craft any body — inherent to role-gated admin; documented. | rbac.guard.ts:43-49, docs/IMPLEMENTATION_PLAN.md:241 |
| P2 | Client sets isSystem:true on create to lock role (no privilege gain — lock is restrictive) | no vector; still forced false in UI | create-role.dto.ts:27, OQ-R2 |
| P3 | Create role with slug org_admin → 409 (unique) — cannot shadow system role | role.schema.ts:89 | — |
| P4 | Crafted perm string '*' or unknown 'a.b.c' in roles → server stores it; guard every() fails for real endpoints (no grant) — matrix whitelist prevents UI path | whitelist | rbac.guard.ts:48 |
| P5 | Member with removed role still calls role-checked endpoint → JWT claim valid until token expiry (≤ 15 m access) — document in security review (OQ-R4) | token TTL | env.ts (15 m access), auth.service.ts:192-196 |
| P6 | platform_admin tries /rbac/* → 403 (not org_admin) — no tenant cross-read via RBAC UI | rbac.controller.ts:21 | — |
| P7 | Audit: RBAC writes must appear in audit-logs — verify action strings recorded for role/member CRUD (exact strings OQ-R8) | PLAN.md:189-191 (19.x) | — |
6. Wire-contract checks
| # | Check |
|---|---|
| W1 | Success envelope: {success:true, message:"OK", data, meta?, timestamp, requestId} — arrays without meta for roles/members; with meta for audit |
| W2 | Error codes: 400 VALIDATION_ERROR (field details), 403 PERMISSION_DENIED, 404 RESOURCE_NOT_FOUND, 409 DUPLICATE_RESOURCE, 429 RATE_LIMITED, 5xx INTERNAL_SERVER_ERROR |
| W3 | requestId echoed; client-generated x-request-id honored |
| W4 | CastError (bad :id) → 400 "Invalid resource identifier." |
| W5 | 429 on RBAC in production only; countdown copy; no auto-retry |
7. Performance & a11y (from 00-shared/10, RBAC-specific)
- Matrix first frame < 300 ms (cached catalog), scroll 60 fps on mid-range device;
RepaintBoundaryper group (15 §2). - TalkBack: matrix chip toggle announces group + checked state; audit table headers announced; typed self-removal confirm readable.
- Text scale 2× on matrix/roles/members; dynamic type no clipping (
M8). - Dark/light golden per screen (S1–S6).
8. Regression hooks (test plan linkage)
- Unit:
PermissionMirrorTTL/invalidate; matrix tri-state logic; slug validator. - Widget: S1–S6 × {loading, error, empty, content}; 403 screen.
- Integration: J1 (create role), J3 (add member), J4 (accountant/HR custom role),
J5 (guard-denied) — mapped to
PLAN.md:17(1.5),PLAN.md:107-110(10.x). - E2E: cross-tenant isolation (T1–T6) with two seeded tenants.