Skip to content

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 guardians table)
  • VOLUNTARIO should only see students from assigned classes (class_assignments table)
  • COORDENACAO_KIDS should see all classes, but read-only

The current model has no concept of "same permission, different scopes."

Alternatives considered

AlternativeDescriptionRejected because
New permissions per scopeCreate students.search_own, students.search_assigned, students.search_allExplodes the permission matrix (N roles × M scopes). Does not scale.
Middleware per roleA requireParentScope(), requireVolunteerScope(), etc. middlewareCoupling role↔middleware. Each new role = new middleware.
Scope System (chosen)ScopeFilter as an injected parameter in queriesSeparates 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)

typescript
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)

typescript
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):

typescript
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):

typescript
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:

typescript
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_own AND students.read_basic
  • Scope: intersection (most restrictive) — sees children that are ALSO in assigned classes
typescript
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: ScopeFilter is a discriminated union. TypeScript exhausts cases in switch.
  • Testable: resolveScope is a pure function. applyScopeToSQL is 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: resolveScope may require 1-2 additional queries to resolve scope IDs.

Mitigated risks

RiskMitigation
Scope leaks datasanitizeForRole is called on EVERY API response that returns entities
Performance (N+1)Scope IDs are cached per request (TTL = handler duration)
Scope broken by migrationIntegration 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 userId from the JWT is the key to resolve own_children and assigned_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
Q1Union of scopes on role accumulation. PAIS + VOLUNTARIO = own_children ∪ assigned_classes.
Q2VOLUNTEER starts with scope all (current behavior). Migrates to assigned_classes when class_assignments exists.
Q3sanitizeForRole applied at Worker (API response layer). Sensitive data never leaves the server.
Q4class_assignments table in migration 0014 together with guardians. Complete scope system in one migration.
Q5Username dedup by guardianName + birthDate. Reuse existing LocalUser on re-submission.
Q6Batch upload is separate issue — not mixed with #195. #277 (XLS import) already exists.

Flow diagram

mermaid
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"]

Distributed under MIT License.