/** * Permission-matrix audit. * * Walks every src/app/api/v1/** /route.ts file and reports each exported HTTP * handler (GET/POST/PUT/PATCH/DELETE) that is *not* wrapped in withPermission(). * Internal v1 routes should be permission-gated; routes that intentionally use * withAuth() alone (e.g. user-self endpoints) can be allow-listed below. * * Run: * pnpm tsx scripts/audit-permissions.ts * * Exit code: * 0 — every handler is permission-gated or in the allow-list * 1 — at least one handler is missing both a withPermission wrapper and an * allow-list entry. CI should fail. */ import { readdir, readFile } from 'node:fs/promises'; import { join, relative } from 'node:path'; const ROOT = join(process.cwd(), 'src/app/api/v1'); const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] as const; /** * Routes intentionally exempt from withPermission. Each entry should explain * why — typically because the route operates on the caller's own resources * (no port-level permission semantics) or is admin-only and gated by * isSuperAdmin inside the handler. */ const ALLOW_LIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [ // Self / admin / public { pattern: /\/me\/route\.ts$/, reason: 'Self-endpoint — auth is sufficient.' }, { pattern: /\/admin\//, reason: 'Admin-only — gated by isSuperAdmin inside handler.' }, { pattern: /\/notifications\//, reason: 'User-scoped notifications — caller is the resource owner.', }, { pattern: /\/socket\//, reason: 'Socket auth handshake.' }, { pattern: /\/health\//, reason: 'Public health check.' }, { pattern: /\/users\/me\//, reason: 'User-self preferences — caller is the resource owner.' }, { pattern: /\/saved-views\//, reason: 'User-self saved views — caller is the resource owner.' }, { pattern: /\/settings\/feature-flag\//, reason: 'Public read of feature-flag bool — no PII; auth is sufficient.', }, // Cross-cutting / port-scoped reference data { pattern: /\/tags\//, reason: 'Tags are cross-cutting reference data; port-scoped via auth.' }, { pattern: /\/currency\/(convert|rates)\/route\.ts$/, reason: 'Currency reference data; port-scoped, no PII.', }, { pattern: /\/currency\/rates\/refresh\//, reason: 'TODO: gate with admin:manage_settings — currently allow-listed.', }, { pattern: /\/search\//, reason: 'Port-scoped search — results filtered by auth context (resources have own perms).', }, // Alerts surface in topbar/dashboard for every signed-in user; per-port not per-resource. { pattern: /\/alerts\//, reason: 'Alerts are user-scoped; port-filtered via auth context.' }, // Internally gated by isSuperAdmin { pattern: /\/expenses\/export\/parent-company\//, reason: 'Internally gated by isSuperAdmin inside the handler.', }, // Pending dedicated permissions { pattern: /\/ai\//, reason: 'TODO: needs ai:* permission catalog entry. Currently allow-listed.', }, { pattern: /\/custom-fields\/\[entityId\]\//, reason: 'TODO: needs custom_fields:* permission. PUT path internally validated.', }, { pattern: /\/berth-reservations\/\[id\]\/route\.ts$/, reason: 'TODO: PATCH should map to reservations:edit (not currently in catalog).', }, ]; interface Finding { file: string; method: string; reason: 'no-withPermission' | 'no-withAuth' | 'allow-listed'; allowReason?: string; } async function* walk(dir: string): AsyncGenerator { for (const entry of await readdir(dir, { withFileTypes: true })) { const path = join(dir, entry.name); if (entry.isDirectory()) yield* walk(path); else if (entry.isFile() && entry.name === 'route.ts') yield path; } } function isAllowListed(file: string): { allowed: boolean; reason?: string } { for (const { pattern, reason } of ALLOW_LIST) { if (pattern.test(file)) return { allowed: true, reason }; } return { allowed: false }; } async function auditFile(file: string): Promise { const src = await readFile(file, 'utf-8'); const findings: Finding[] = []; for (const method of HTTP_METHODS) { // Match: export const GET = withAuth(... const declRe = new RegExp(`export\\s+const\\s+${method}\\s*=\\s*(.+?);`, 's'); const m = declRe.exec(src); if (!m) continue; const block = m[1] ?? ''; const hasAuth = /withAuth\s*\(/.test(block); const hasPerm = /withPermission\s*\(/.test(block); const allow = isAllowListed(file); if (!hasAuth) { findings.push({ file, method, reason: 'no-withAuth' }); continue; } if (!hasPerm) { if (allow.allowed) { findings.push({ file, method, reason: 'allow-listed', allowReason: allow.reason }); } else { findings.push({ file, method, reason: 'no-withPermission' }); } } } return findings; } async function main() { const files: string[] = []; for await (const f of walk(ROOT)) files.push(f); files.sort(); const all: Finding[] = []; for (const f of files) all.push(...(await auditFile(f))); const violations = all.filter( (f) => f.reason === 'no-withPermission' || f.reason === 'no-withAuth', ); const allowListed = all.filter((f) => f.reason === 'allow-listed'); // Markdown report const lines: string[] = []; lines.push('# Permission Matrix Audit'); lines.push(''); lines.push(`Scanned ${files.length} route files under \`src/app/api/v1/\`.`); lines.push(''); if (violations.length === 0) { lines.push('**No violations.** Every internal v1 handler is permission-gated.'); } else { lines.push(`**${violations.length} violation(s):**`); lines.push(''); lines.push('| File | Method | Issue |'); lines.push('| --- | --- | --- |'); for (const v of violations) { const rel = relative(process.cwd(), v.file); lines.push(`| \`${rel}\` | ${v.method} | ${v.reason} |`); } } lines.push(''); lines.push( `**Allow-listed:** ${allowListed.length} handler(s) intentionally skip \`withPermission\`.`, ); if (allowListed.length > 0) { lines.push(''); lines.push('| File | Method | Reason |'); lines.push('| --- | --- | --- |'); for (const a of allowListed) { const rel = relative(process.cwd(), a.file); lines.push(`| \`${rel}\` | ${a.method} | ${a.allowReason} |`); } } process.stdout.write(lines.join('\n') + '\n'); process.exit(violations.length > 0 ? 1 : 0); } main().catch((err) => { console.error(err); process.exit(2); });