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
| Alternativa | Descrição | Rejeitada porque |
|---|---|---|
| Novas permissões por escopo | Criar students.search_own, students.search_assigned, students.search_all | Explode a matriz de permissões (N papéis × M escopos). Não escala. |
| Middleware por papel | Um middleware requireParentScope(), requireVolunteerScope(), etc. | Acoplamento papel↔middleware. Cada novo papel = novo middleware. |
| Scope System (escolhido) | ScopeFilter como parâmetro injetado nas queries | Separa 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)
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)
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):
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):
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:
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_ownEstudents.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.
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 noswitch. - Testável:
resolveScopeé função pura.applyScopeToSQLé testável com mock D1.
Negativas
- Complexidade nas queries: Todo serviço que lista entidades precisa aceitar
scopeopcional. - Duas fontes de verdade: O escopo depende de tabelas auxiliares (
guardians,class_assignments) que precisam ser mantidas em sync. - Latência extra:
resolveScopepode exigir 1-2 queries adicionais para resolver os IDs do escopo.
Riscos mitigados
| Risco | Mitigação |
|---|---|
| Scope vaza dados | sanitizeForRole é 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ção | Testes 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
userIddo JWT é a chave para resolverown_childreneassigned_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 |
|---|---|
| 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. |
Diagrama de fluxo
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"]