Skip to content

ADR-0016: RBAC Scope System — Permissões com Escopo por Entidade

Context

O sistema de permissões atual (packages/permissions/index.ts) é flat: um papel tem uma lista de permissões atômicas (students.search, reports), e toda query que verifica requireRole(["ADMIN"]) concede acesso total a todos os registros.

Isso é suficiente para papéis administrativos (ADMIN, CADASTRO), mas insuficiente para papéis com escopo restrito:

  • PAIS deve ver apenas os próprios filhos (vinculados via tabela guardians)
  • VOLUNTARIO deve ver apenas alunos das turmas assigned (tabela class_assignments)
  • COORDENACAO_KIDS deve ver todas as turmas, mas read-only

O modelo atual não tem conceito de "mesma permissão, escopos diferentes".

Alternativas consideradas

AlternativaDescriçãoRejeitada porque
Novas permissões por escopoCriar students.search_own, students.search_assigned, students.search_allExplode a matriz de permissões (N papéis × M escopos). Não escala.
Middleware por papelUm middleware requireParentScope(), requireVolunteerScope(), etc.Acoplamento papel↔middleware. Cada novo papel = novo middleware.
Scope System (escolhido)ScopeFilter como parâmetro injetado nas queriesSepara o quê (permissão) de onde (escopo). Compõe com papéis existentes.

Decision

Implementamos um Scope System em 3 camadas:

1. Tipo ScopeFilter (packages/permissions/scope.ts)

typescript
type ScopeFilter =
  | { type: "all" }
  | { type: "own_children"; userId: string }
  | { type: "assigned_classes"; userId: string }

Cada papel mapeia para um ScopeFilter via resolveScope(role, userId).

2. Resolução de escopo (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",
    // Papéis legados — sem escopo
    ADMIN: "all",
    CHAMADOR: "all",
    RELATORIOS: "all",
    CADASTRO: "all",
    VOLUNTEER: "assigned_classes",
  };
  return { type: mapping[role] ?? "all", userId };
}

3. Aplicação do escopo nas 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" → sem filtro adicional

  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. Sanitização de campos sensíveis

Papéis com escopo restrito também têm restrição de campos:

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 — acesso total
}

5. Acumulação de papéis (multi-role)

Quando um usuário tem múltiplos papéis (ex.: PAIS + VOLUNTARIO):

  • Permissões: união (mais permissiva) — tem students.read_own E students.read_basic
  • Escopo: união (mais permissiva) — vê filhos que estão em turmas assigned OU são filhos (combinação via "multi" compound type)
  • Sanitização: mais restritiva — se QUALQUER papel exige sanitização, aplica-se

Nota: A seção 5 foi corrigida pelo grill de 2026-07-05 (Q1). O comportamento de interseção foi substituído por união de escopos, alinhado com ADR-0022.

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: union of filters (OR)
  return { type: "multi", filters: scopes };
}

Consequences

Positivas

  • Separação de concerns: Permissão = "pode ver alunos?". Escopo = "quais alunos?"
  • Composição: Novos papéis definem apenas [permissions] + scopeType. O middleware aplica ambos.
  • Zero breaking changes: Papéis legados mapeiam para ScopeFilter(type: "all") — comportamento idêntico.
  • Type-safe: ScopeFilter é um discriminated union. TypeScript exaure os casos no switch.
  • Testável: resolveScope é função pura. applyScopeToSQL é testável com mock D1.

Negativas

  • Complexidade nas queries: Todo serviço que lista entidades precisa aceitar scope opcional.
  • Duas fontes de verdade: O escopo depende de tabelas auxiliares (guardians, class_assignments) que precisam ser mantidas em sync.
  • Latência extra: resolveScope pode exigir 1-2 queries adicionais para resolver os IDs do escopo.

Riscos mitigados

RiscoMitigação
Scope vaza dadossanitizeForRole é chamado em TODA resposta de API que retorna entidades
Performance (N+1)Scope IDs são cacheados por request (TTL = duração do handler)
Escopo quebrado por migraçãoTestes de integração verificam listStudents com cada ScopeFilter

Relação com outros ADRs

  • ADR-0001 (Cloudflare D1): O scope system adiciona WHERE clauses dinâmicas nas queries D1
  • ADR-0008 (Auth JWT): O userId do JWT é a chave para resolver own_children e assigned_classes
  • #154 (RBAC Expandido): Implementação concreta dos 4 novos papéis usando este sistema

Status

Proposed — aguardando implementação em #154. O contrato de tipos (ScopeFilter, resolveScope) está especificado mas o código ainda não existe.

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.

Diagrama de fluxo

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["Sem filtro adicional"]
    Guardians --> SQL["WHERE student_id IN (...)"]
    Assignments --> SQL
    NoFilter --> SQL
    SQL --> sanitize["sanitizeForRole()"]
    sanitize --> Response["API Response"]

Distribuído sob licença MIT.