ADR-0016: RBAC Scope System — Entity-Scoped Permissions
Context
The current permissions system (packages/permissions/index.ts) is flat: a role has a list of atomic permissions (students.search, reports), and every query that checks requireRole(["ADMIN"]) grants full access to all records.
This is sufficient for administrative roles (ADMIN, CADASTRO), but insufficient for roles with restricted scope:
- PAIS should only see their own children (linked via the
guardianstable) - VOLUNTARIO should only see students from assigned classes (
class_assignmentstable) - COORDENACAO_KIDS should see all classes, but read-only
The current model has no concept of "same permission, different scopes."
Alternatives considered
| Alternative | Description | Rejected because |
|---|---|---|
| New permissions per scope | Create students.search_own, students.search_assigned, students.search_all | Explodes the permission matrix (N roles × M scopes). Does not scale. |
| Middleware per role | A requireParentScope(), requireVolunteerScope(), etc. middleware | Coupling role↔middleware. Each new role = new middleware. |
| Scope System (chosen) | ScopeFilter as an injected parameter in queries | Separates what (permission) from where (scope). Composes with existing roles. |
Decision
We implement a Scope System in 3 layers:
1. ScopeFilter type (packages/permissions/scope.ts)
type ScopeFilter =
| { type: "all" }
| { type: "own_children"; userId: string }
| { type: "assigned_classes"; userId: string }Each role maps to a ScopeFilter via resolveScope(role, userId).
2. Scope resolution (resolveScope)
function resolveScope(role: UserRole, userId: string): ScopeFilter {
const mapping: Record<string, ScopeFilter["type"]> = {
PAIS: "own_children",
VOLUNTARIO: "assigned_classes",
COORDENACAO_KIDS: "all",
ADMINISTRATIVO_KIDS: "all",
// Legacy roles — no scope
ADMIN: "all",
CHAMADOR: "all",
RELATORIOS: "all",
CADASTRO: "all",
VOLUNTEER: "assigned_classes",
};
return { type: mapping[role] ?? "all", userId };
}3. Scope application in queries
Worker (SQL):
async function listStudents(db: D1Database, opts: ListOptions & { scope?: ScopeFilter }) {
let where = "WHERE status = 'ACTIVE'";
const params: unknown[] = [];
if (opts.scope?.type === "own_children") {
const children = await getChildIds(db, opts.scope.userId);
if (children.length === 0) return { items: [], nextCursor: null };
where += ` AND student_id IN (${children.map(() => "?").join(",")})`;
params.push(...children);
} else if (opts.scope?.type === "assigned_classes") {
const classes = await getAssignedClasses(db, opts.scope.userId);
if (classes.length === 0) return { items: [], nextCursor: null };
where += ` AND class_id IN (${classes.map(() => "?").join(",")})`;
params.push(...classes);
}
// type === "all" → no additional filter
return db.prepare(`SELECT * FROM students ${where} LIMIT ?`).bind(...params, opts.limit).all();
}Frontend (SQLite WASM):
function useScopedQuery(sql: string, scope?: ScopeFilter) {
const { data: scopeIds } = useResolvedScope(scope); // resolve guardians/assignments
if (scope?.type === "own_children" && scopeIds?.length === 0) {
return { data: [], loading: false }; // early return — no children
}
const scopedSql = applyScopeToSQL(sql, scope, scopeIds);
return useSqlQuery(scopedSql);
}4. Sensitive field sanitization
Roles with restricted scope also have field restrictions:
const SENSITIVE_FIELDS = ["addressStreet", "addressNumber", "addressCity", /* ... */];
function sanitizeForRole<T extends Record<string, unknown>>(
record: T,
role: UserRole,
): Partial<T> {
if (role === "VOLUNTARIO" || role === "PAIS") {
const sanitized = { ...record };
for (const field of SENSITIVE_FIELDS) delete sanitized[field];
return sanitized;
}
return record; // ADMIN, COORDENACAO_KIDS, ADMINISTRATIVO_KIDS — full access
}5. Role accumulation
When a user has multiple roles (e.g., PAIS + VOLUNTARIO):
- Permissions: union (most permissive) — has
students.read_ownANDstudents.read_basic - Scope: intersection (most restrictive) — sees children that are ALSO in assigned classes
function mergeScopes(scopes: ScopeFilter[]): ScopeFilter {
if (scopes.length === 0) return { type: "all" };
if (scopes.every(s => s.type === "all")) return { type: "all" };
// Multi-scope: apply all filters (AND)
return { type: "multi", filters: scopes };
}Consequences
Positive
- Separation of concerns: Permission = "can see students?". Scope = "which students?"
- Composition: New roles only define
[permissions] + scopeType. The middleware applies both. - Zero breaking changes: Legacy roles map to
ScopeFilter(type: "all")— identical behavior. - Type-safe:
ScopeFilteris a discriminated union. TypeScript exhausts cases inswitch. - Testable:
resolveScopeis a pure function.applyScopeToSQLis testable with a mock D1.
Negative
- Query complexity: Every service that lists entities must accept an optional
scope. - Two sources of truth: The scope depends on auxiliary tables (
guardians,class_assignments) that must be kept in sync. - Extra latency:
resolveScopemay require 1-2 additional queries to resolve scope IDs.
Mitigated risks
| Risk | Mitigation |
|---|---|
| Scope leaks data | sanitizeForRole is called on EVERY API response that returns entities |
| Performance (N+1) | Scope IDs are cached per request (TTL = handler duration) |
| Scope broken by migration | Integration tests verify listStudents with each ScopeFilter |
Relationship with other ADRs
- ADR-0001 (Cloudflare D1): The scope system adds dynamic WHERE clauses to D1 queries
- ADR-0008 (Auth JWT): The
userIdfrom the JWT is the key to resolveown_childrenandassigned_classes - #154 (Expanded RBAC): Concrete implementation of the 4 new roles using this system
Status
Proposed — awaiting implementation in #154. The type contract (ScopeFilter, resolveScope) is specified but the code does not yet exist.
Decisions from Grill (2026-06-26)
| # | Decision |
|---|---|
| Q1 | Union of scopes on role accumulation. PAIS + VOLUNTARIO = own_children ∪ assigned_classes. |
| Q2 | VOLUNTEER starts with scope all (current behavior). Migrates to assigned_classes when class_assignments exists. |
| Q3 | sanitizeForRole applied at Worker (API response layer). Sensitive data never leaves the server. |
| Q4 | class_assignments table in migration 0014 together with guardians. Complete scope system in one migration. |
| Q5 | Username dedup by guardianName + birthDate. Reuse existing LocalUser on re-submission. |
| Q6 | Batch upload is separate issue — not mixed with #195. #277 (XLS import) already exists. |
Flow diagram
graph TD
JWT["JWT { userId, roles }"] --> resolveScope
resolveScope["resolveScope(role, userId)"] --> ScopeFilter
ScopeFilter -->|"type: own_children"| Guardians["Query guardians table"]
ScopeFilter -->|"type: assigned_classes"| Assignments["Query class_assignments"]
ScopeFilter -->|"type: all"| NoFilter["No additional filter"]
Guardians --> SQL["WHERE student_id IN (...)"]
Assignments --> SQL
NoFilter --> SQL
SQL --> sanitize["sanitizeForRole()"]
sanitize --> Response["API Response"]