13 — State Management (RBAC Module)
- 1. Cubits
- 2.
PermissionMirror— client-side permission state (core) - 3. Cross-screen data flow
- 4. Guard composition (client routes)
- 5. Editor state persistence
- 6. Member merge strategy
- 7. Optimistic updates — allowed list
- 8. Realtime & refresh
- 9. Testing hooks
Cubit architecture for the RBAC surface, on 00-shared/06. Server model recap: JWT carries
rolesclaim (minted at login,auth.service.ts:145-154); effective permissions resolve via Redis-cachedgetPermissionsForUser(rbac.service.ts:44-73); the client mirrors this with its own permission cache.
1. Cubits
| Cubit | State | API |
|---|---|---|
RoleListCubit | LoadState + List<Role> + searchQuery | GET /rbac/roles |
RoleEditorCubit | LoadState + Role? + Set<String> selected + List<String> catalog + formFields + saving | GET /rbac/permissions, POST /rbac/roles, PATCH /rbac/roles/:id |
MemberListCubit | LoadState + List<MemberView> + searchQuery | GET /rbac/members + user.read merge |
MemberSheetCubit | LoadState + User? selected + Set<String> roles + saving + editMode | POST /rbac/members, PATCH /rbac/members/:id, GET /rbac/roles (picker) |
AuditCubit | LoadState + List<AuditRow> + filters + PaginatedListMixin | GET /audit-logs |
All follow LoadState (00-shared/06 §3.1); audit uses PaginatedListMixin (§3.2).
Events: Load, Refresh, Retry, ChangeSearch, TogglePermission,
ToggleGroup, Save, ClearFilters — naming per 00-shared/06 §4.
2. PermissionMirror — client-side permission state (core)
The client's analog of rbac.service.ts:44-73:
class PermissionMirror {
// key: 'sl:{tenantId}:perm:{userId}' — mirrors server key shape (rbac.service.ts:48)
final Map<String, List<String>> _cache; // in-memory
final HiveCache _hive; // persistent, per tenant
// stale-while-revalidate: serve cached, refresh ≤ 300 s TTL (server EX 300,
// rbac.service.ts:68), rebuild on miss
Future<Set<String>> permissionsFor(String userId, String tenantId);
void invalidate(String tenantId, String userId); // after role/member edits by self
}
Rules:
- Server is authoritative. The mirror is a cache, never a decision-maker — every critical action still surfaces server 403s.
- TTL mirrors the server: ≤ 300 s freshness;
invalidateson own writes (create/ update/delete role, add/update/remove member) and on login. - Route rebuild: when the mirror resolves a changed permission set for the current
user (login, manual refresh, own RBAC write, session resume), emit
permissionsChanged→AppRouter.refresh()→ hidden/unroutable destinations update (05 §9,11_Flutter_App_Architecture.md §6). - JWT roles claim is separate (
jwt-payload.interface.ts:3):AuthCubitholds it; role-claim changes require re-login (OQ-R4) — the client offers "Refresh session" after self-role edits (09 §3).
3. Cross-screen data flow
AppShell (05 §1)
├─ AuthCubit → roles claim (JWT) + userId + tenantId
├─ PermissionMirror → Set<String> myPermissions (SWR, 300 s)
└─ FeatureFlagsCubit → gates coaching perms (planned) — render only when server ships
Router guards (permissionGuard('rbac.role.read'), …) — 15 §5
│
▼
RBAC screens → module cubits (above) → repositories (dio) → API
- Shared selectors:
currentUser,currentTenant(00-shared/06 §4). - No global singleton holds screen state; cubits are
get_itlazy factories.
4. Guard composition (client routes)
Current server reality (role-gated /rbac/*, rbac.controller.ts:21) vs intended
perm-gated model → combined guard (see 04 §3):
roleOrPermissionGuard(roles: ['org_admin'], permissions: ['rbac.role.read'])
Resolves true if the JWT claims org_admin or the mirror grants rbac.role.read —
removes screens for both the teacher (no role, no perm) and the future role-manager
(perm yes, role no) as OQ-R1 evolves. Audit route: permissionGuard('audit.read')
(server lags, audit.controller.ts:9 — (planned)).
5. Editor state persistence
RoleEditorCubitholdsselected(Set) across backgrounding/session expiry (in-memory; survives re-login per 09 §9).- Dirty tracking:
formFields != originalorselected != role.permissions→ back confirm (10 §4). - Save: one PATCH with the full array (
rbac.service.ts:96); no per-cell writes.
6. Member merge strategy
MemberListCubit fetches GET /rbac/members + GET /api/v1/users (user.read, cached
24 h) → MemberView {member, user?}; missing user → fallback tile (userId mono,
06 §S4). Merge is display-only; writes send member.userId unchanged (OQ-R7).
7. Optimistic updates — allowed list
| Action | Optimistic? | Rule |
|---|---|---|
| Toggle permission / group | yes (local set) | single PATCH on Save; rollback on error |
| Add member | no | server-confirm, then insert row |
| Remove member / delete role | no | server-confirm, then remove row (irreversible-ish, 09 §4) |
| Role perm count badge | yes | derived from editor state |
8. Realtime & refresh
- No WS topic for RBAC changes exists (server
WsModuletopics list has none —00-shared/07 §8; OQ-R9). Cross-admin freshness = pull-to-refresh + stale-while- revalidate only. - On app resume: refetch roles/members lists if stale > 5 min; re-validate mirror.
9. Testing hooks
- Cubits pure-Dart with mocked repositories;
PermissionMirrorunit-tested for SWR/TTL/invalidate semantics; widget tests for 3-state screens (00-shared/06 §6).