Multi-area cleanup pass closing partial-implementation gaps surfaced by the
post-i18n audit. No behavior changes for happy-path users; closes real
correctness/security holes.
PR1a Public yacht-interest endpoint i18n. /api/public/interests now accepts
phoneE164/phoneCountry, nationalityIso, address.{countryIso, subdivisionIso},
and company.{incorporationCountryIso, incorporationSubdivisionIso}.
Server-side parsePhone() fallback for legacy raw phone strings.
PR1b Alert rule registry trim. Two rule slots ('document.expiring_soon',
'audit.suspicious_login') were registered but evaluators returned [].
Both required schema/instrumentation that hadn't landed. Removed from
the registry; comments record the dependencies needed to revive them.
Effective rule count: 8 active.
PR1c vi.mock hoist + flake fix. Hoisted vi.mock calls to top-level in 5
integration test files; webhook-delivery uses vi.hoisted for the
queue-add ref. Vitest no longer warns about non-top-level mocks.
Deflaked the 'short value' assertion in security-encryption.test.ts
by switching plaintext from 'ab' to 'XY' (non-hex chars). 5/5 runs green.
PR1d Soft-delete reference audit. listClientOptions and listYachtsForOwner
now filter by isNull(archivedAt). Berths use status (no archivedAt).
PR1e Permission-matrix audit script + report. scripts/audit-permissions.ts
walks every src/app/api/v1/**/route.ts and reports handlers without a
withPermission() wrapper. Initial run found 33 violations.
- Allow-listed 17 with explicit reasons (self-data, admin, alerts,
search, currency, ai, custom-fields — some marked TODO).
- Wrapped 7 routes with concrete permissions: clients/options
(clients:view), berths/options (berths:view), dashboard/*
(reports:view_dashboard), analytics (reports:view_analytics).
Audit report at docs/runbooks/permission-audit.md. Script exits
non-zero on any unallow-listed violation so it can become a CI gate.
Vitest: 741 -> 741 (no new tests; existing suite covers the changes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
189 lines
6.4 KiB
TypeScript
189 lines
6.4 KiB
TypeScript
/**
|
|
* 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<string> {
|
|
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<Finding[]> {
|
|
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);
|
|
});
|