← back to Norma
test(cycle7 Cody-gate): mutation role-matrix + isolation positive-control
a945908c2833dcf1a109e9520d1cf36823355368 · 2026-08-06 14:17:17 -0700 · Steve
Cody VERIFIED 3 real gaps: (1) matrix was GET-only — 216 mutation handlers untested for
role-gating; (2) tenant-isolation passed VACUOUSLY (asserted B absent but never that staff
sees an A record — an empty-for-everyone list passes falsely); (3) bespoke-auth routes
(/api/registry) silently skipped.
- Matrix now covers mutations too: for each POST/PUT/PATCH/DELETE handler, every DISALLOWED
role must 403 (requireRole rejects before any write/send side-effect, so probing is safe).
180 GET + 201 mutation handlers, 0 under-restrictions.
- Isolation positive control: admin creates a tenant-A AND a tenant-B record; assert staff(A)
SEES the A record (list + by-id 200) BEFORE asserting B is absent (by-id 404/403 + not in
list). Kills the vacuous-truth trap. Both records cleaned up.
- Added /api/registry (bespoke admin-only auth) as an explicit matrix entry.
Full battery (6 suites): api 195 / write 216 / crud 35 / authz 12 / regression 20 / e2e 9.
Files touched
M tests/api-authz-matrix.mjs
Diff
commit a945908c2833dcf1a109e9520d1cf36823355368
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Aug 6 14:17:17 2026 -0700
test(cycle7 Cody-gate): mutation role-matrix + isolation positive-control
Cody VERIFIED 3 real gaps: (1) matrix was GET-only — 216 mutation handlers untested for
role-gating; (2) tenant-isolation passed VACUOUSLY (asserted B absent but never that staff
sees an A record — an empty-for-everyone list passes falsely); (3) bespoke-auth routes
(/api/registry) silently skipped.
- Matrix now covers mutations too: for each POST/PUT/PATCH/DELETE handler, every DISALLOWED
role must 403 (requireRole rejects before any write/send side-effect, so probing is safe).
180 GET + 201 mutation handlers, 0 under-restrictions.
- Isolation positive control: admin creates a tenant-A AND a tenant-B record; assert staff(A)
SEES the A record (list + by-id 200) BEFORE asserting B is absent (by-id 404/403 + not in
list). Kills the vacuous-truth trap. Both records cleaned up.
- Added /api/registry (bespoke admin-only auth) as an explicit matrix entry.
Full battery (6 suites): api 195 / write 216 / crud 35 / authz 12 / regression 20 / e2e 9.
---
tests/api-authz-matrix.mjs | 155 +++++++++++++++++++++++++++++----------------
1 file changed, 99 insertions(+), 56 deletions(-)
diff --git a/tests/api-authz-matrix.mjs b/tests/api-authz-matrix.mjs
index 0c615d5..a3e4539 100644
--- a/tests/api-authz-matrix.mjs
+++ b/tests/api-authz-matrix.mjs
@@ -40,7 +40,29 @@ function ok(cond, name, detail = '') {
else { fail++; fails.push(`${name}${detail ? ' — ' + detail : ''}`); }
}
-// ── Derive each GET route's allowed roles from its requireRole(...) call ───────
+const VERBS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
+// Isolate one handler's body from the file (verb V until the next exported handler).
+function handlerBody(src, verb) {
+ const m = new RegExp(`export\\s+(?:async\\s+)?function\\s+${verb}\\b`).exec(src);
+ if (!m) return null;
+ const after = src.slice(m.index + 10);
+ const next = /export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\b/.exec(after);
+ return next ? after.slice(0, next.index) : after;
+}
+// Parse the allowed-role set from a handler body's requireRole(...) call.
+function allowedRoles(body, src) {
+ const rr = /requireRole\(\s*\w+\s*,\s*([^)]*)\)/.exec(body);
+ if (!rr) return null; // no requireRole → custom/public guard, not covered here
+ const spread = /\.\.\.(\w+)/.exec(rr[1]);
+ if (spread) {
+ // Resolve `...VAR` from a local `const VAR = [...]`; a spread is NOT always all roles.
+ const cd = new RegExp(`\\b${spread[1]}\\b[^=]*=\\s*\\[([^\\]]*)\\]`).exec(src);
+ if (cd) return new Set([...cd[1].matchAll(/'([a-z]+)'/g)].map((x) => x[1]));
+ return new Set(['admin', 'staff', 'intern', 'pulse']); // ALL_ROLES fallback
+ }
+ return new Set([...rr[1].matchAll(/'([a-z]+)'/g)].map((x) => x[1]));
+}
+// Derive per-(route,verb) allowed roles from each handler's requireRole call.
function getRouteAuthz(dir, rel = '') {
const out = [];
for (const name of readdirSync(dir)) {
@@ -48,31 +70,20 @@ function getRouteAuthz(dir, rel = '') {
if (statSync(abs).isDirectory()) { out.push(...getRouteAuthz(abs, `${rel}/${name}`)); continue; }
if (name !== 'route.ts') continue;
const src = readFileSync(abs, 'utf8');
- // isolate the GET handler body
- const m = /export\s+(?:async\s+)?function\s+GET\b/.exec(src);
- if (!m) continue;
- const after = src.slice(m.index);
- const next = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b/.exec(after.slice(10));
- const body = next ? after.slice(0, next.index + 10) : after;
- const rr = /requireRole\(\s*\w+\s*,\s*([^)]*)\)/.exec(body);
- if (!rr) continue; // no requireRole in GET → custom/public guard, skip
- let allowed;
- const spread = /\.\.\.(\w+)/.exec(rr[1]);
- if (spread) {
- // Resolve `...VAR` from a local `const VAR = ['admin','staff',...]` in the file,
- // else the imported ALL_ROLES (all 4 roles). NOT every ...ROLES means "all roles"
- // — some files define a narrower local const (e.g. ['admin','staff']).
- const cd = new RegExp(`\\b${spread[1]}\\b[^=]*=\\s*\\[([^\\]]*)\\]`).exec(src);
- if (cd) allowed = new Set([...cd[1].matchAll(/'([a-z]+)'/g)].map((x) => x[1]));
- else allowed = new Set(['admin', 'staff', 'intern', 'pulse']); // ALL_ROLES fallback
- } else {
- allowed = new Set([...rr[1].matchAll(/'([a-z]+)'/g)].map((x) => x[1]));
- }
const url = '/api' + rel.replace(/\[[^\]]+\]/g, (s) => SEG[s] ?? NIL);
- out.push({ url, allowed });
+ for (const verb of VERBS) {
+ const body = handlerBody(src, verb);
+ if (!body) continue;
+ const allowed = allowedRoles(body, src);
+ if (!allowed) continue;
+ out.push({ url, verb, allowed });
+ }
}
return out;
}
+// Routes with bespoke (non-requireRole) auth the parser can't read — asserted explicitly
+// so a regression is still caught (Cody: /api/registry uses verifySessionToken + admin-only).
+const BESPOKE = [{ url: '/api/registry', verb: 'GET', allowed: new Set(['admin']) }];
async function login(username) {
const res = await fetch(`${BASE}/api/auth/login`, {
@@ -81,9 +92,13 @@ async function login(username) {
});
return (res.headers.get('set-cookie') || '').match(/norma-auth=([^;]+)/)?.[1];
}
-async function hit(path, cookie, extra = {}) {
+async function hit(path, cookie, extra = {}, verb = 'GET') {
try {
- const r = await fetch(`${BASE}${path}`, { headers: { ...(cookie ? { Cookie: `norma-auth=${cookie}` } : {}), ...extra }, redirect: 'manual' });
+ const headers = { ...(cookie ? { Cookie: `norma-auth=${cookie}` } : {}), ...extra };
+ // Mutations are probed with an EMPTY body — a disallowed role 403s at requireRole
+ // (the handler's first line) BEFORE any write/send side-effect, so this is safe even
+ // for send/ingest routes.
+ const r = await fetch(`${BASE}${path}`, { method: verb, headers, redirect: 'manual' });
return r.status;
} catch (e) { return `ERR:${e.code || e.message}`; }
}
@@ -122,47 +137,75 @@ async function main() {
for (const r of ROLES) cookies[r] = await login(r === 'admin' ? 'admin' : r === 'staff' ? 'teststaff' : 'testpulse');
if (!cookies.admin) { console.error('\x1b[31mFATAL\x1b[0m admin login failed'); process.exit(2); }
- // ── 1) ROLE MATRIX ──────────────────────────────────────────────────────────
- const routes = getRouteAuthz(API_DIR);
- console.log(`ROLE MATRIX — ${routes.length} requireRole-guarded GET routes × ${ROLES.length} roles`);
- const underRestrict = []; // disallowed role got in (security hole)
- const overRestrict = []; // allowed role wrongly 403'd
- await pool(routes, async (rt) => {
+ // ── 1) ROLE MATRIX (GET + mutations) ─────────────────────────────────────────
+ const all = [...getRouteAuthz(API_DIR), ...BESPOKE];
+ const gets = all.filter((r) => r.verb === 'GET');
+ const mutations = all.filter((r) => r.verb !== 'GET');
+ console.log(`ROLE MATRIX — ${gets.length} GET + ${mutations.length} mutation handlers × roles`);
+ const underRestrict = []; // disallowed role got in (privilege-escalation)
+ const overRestrict = []; // allowed role wrongly 403'd (GET only)
+
+ // GET: test all roles (reads are safe) — disallowed→403, allowed→not-403.
+ await pool(gets, async (rt) => {
for (const role of ROLES) {
const st = await hit(rt.url, cookies[role]);
if (typeof st !== 'number') continue;
const allowed = rt.allowed.has(role);
- if (!allowed && st !== 403) underRestrict.push(`${role} reached ${rt.url} (allowed=${[...rt.allowed]}) → ${st}`);
- if (allowed && st === 403) overRestrict.push(`${role} 403'd on ${rt.url} (allowed=${[...rt.allowed]})`);
+ if (!allowed && st !== 403) underRestrict.push(`GET ${rt.url}: ${role} → ${st} (allowed=${[...rt.allowed]})`);
+ if (allowed && st === 403) overRestrict.push(`GET ${rt.url}: ${role} wrongly 403 (allowed=${[...rt.allowed]})`);
}
});
- ok(underRestrict.length === 0, 'no privilege-escalation (disallowed role never bypasses 403)',
- underRestrict.slice(0, 12).join(' | '));
- ok(overRestrict.length === 0, 'no over-restriction (allowed role never wrongly 403s)',
+ // MUTATIONS: test only DISALLOWED roles → must 403 (requireRole rejects before any
+ // side-effect, so no write/send happens). This is the write-path privilege-escalation check.
+ await pool(mutations, async (rt) => {
+ for (const role of ROLES) {
+ if (rt.allowed.has(role)) continue; // never fire an ALLOWED mutation (would write/send)
+ const st = await hit(rt.url, cookies[role], {}, rt.verb);
+ if (typeof st !== 'number') continue;
+ if (st !== 403) underRestrict.push(`${rt.verb} ${rt.url}: ${role} → ${st} (allowed=${[...rt.allowed]})`);
+ }
+ });
+ ok(underRestrict.length === 0, 'no privilege-escalation (disallowed role never bypasses 403, GET+mutations)',
+ underRestrict.slice(0, 15).join(' | '));
+ ok(overRestrict.length === 0, 'no over-restriction (allowed role never wrongly 403s on GET)',
overRestrict.slice(0, 12).join(' | '));
console.log(` under-restrictions (security): ${underRestrict.length} · over-restrictions: ${overRestrict.length}`);
- // ── 2) TENANT ISOLATION ──────────────────────────────────────────────────────
- console.log(`\nTENANT ISOLATION — staff(A) must not read tenant B's records`);
- const adminB = { 'Content-Type': 'application/json', Cookie: `norma-auth=${cookies.admin}`, 'x-org-id': tenantB };
- for (const ent of [{ p: '/api/donations', body: { amount: 9.99 }, key: 'donation' }, { p: '/api/grants', body: { title: 'B Grant', funder: 'B' }, key: 'grant' }]) {
- // admin creates a tenant-B-owned record
- const cRes = await fetch(`${BASE}${ent.p}`, { method: 'POST', headers: adminB, body: JSON.stringify(ent.body), redirect: 'manual' });
- const cBody = await cRes.json().catch(() => ({}));
- const bId = cBody[ent.key]?.id || cBody.id;
- ok(!!bId, `isolation setup: admin created a tenant-B ${ent.key}`, `status ${cRes.status}`);
- if (!bId) continue;
- // staff(A) tries to read B's record directly
- const direct = await hit(`${ent.p}/${bId}`, cookies.staff);
- ok(direct === 404 || direct === 403, `staff(A) CANNOT read tenant-B ${ent.key} by id`, `got ${direct} (expected 404/403)`);
- // staff(A) list must not include B's record
- const listSt = await fetch(`${BASE}${ent.p}`, { headers: { Cookie: `norma-auth=${cookies.staff}` } });
- const listBody = await listSt.json().catch(() => ({}));
- const rows = listBody[`${ent.key}s`] || listBody.items || listBody.rows || Object.values(listBody).find(Array.isArray) || [];
- const leaked = Array.isArray(rows) && rows.some((r) => r?.id === bId);
- ok(!leaked, `staff(A) list does NOT leak tenant-B ${ent.key}`, leaked ? 'B record visible to A' : '');
- // cleanup as admin(B)
- await fetch(`${BASE}${ent.p}/${bId}`, { method: 'DELETE', headers: adminB, redirect: 'manual' }).catch(() => {});
+ // ── 2) TENANT ISOLATION (with positive control) ──────────────────────────────
+ console.log(`\nTENANT ISOLATION — staff(A) sees A's records, never B's`);
+ const hdr = (org) => ({ 'Content-Type': 'application/json', Cookie: `norma-auth=${cookies.admin}`, 'x-org-id': org });
+ const staffList = async (p) => {
+ const r = await fetch(`${BASE}${p}`, { headers: { Cookie: `norma-auth=${cookies.staff}` } });
+ const b = await r.json().catch(() => ({}));
+ return Object.values(b).find(Array.isArray) || [];
+ };
+ for (const ent of [{ p: '/api/donations', body: { amount: 9.99 }, key: 'donation' }, { p: '/api/grants', body: { title: 'Iso Grant', funder: 'Iso' }, key: 'grant' }]) {
+ // admin creates one record owned by tenant A and one owned by tenant B
+ const mk = async (org) => {
+ const r = await fetch(`${BASE}${ent.p}`, { method: 'POST', headers: hdr(org), body: JSON.stringify(ent.body), redirect: 'manual' });
+ const b = await r.json().catch(() => ({}));
+ return { id: b[ent.key]?.id || b.id, status: r.status };
+ };
+ const A = await mk(tenantA);
+ const B = await mk(tenantB);
+ ok(!!A.id && !!B.id, `isolation setup: admin created tenant-A + tenant-B ${ent.key}`, `A=${A.status}/${A.id} B=${B.status}/${B.id}`);
+ if (!A.id || !B.id) { continue; }
+
+ // POSITIVE CONTROL — staff(A) MUST see the A-owned record (else "B absent" is vacuous)
+ const listRows = await staffList(ent.p);
+ ok(listRows.some((r) => r?.id === A.id), `staff(A) list SEES tenant-A ${ent.key} (positive control)`,
+ `A id ${A.id} not in staff list of ${listRows.length} rows`);
+ // staff(A) can read the A record by id
+ ok((await hit(`${ent.p}/${A.id}`, cookies.staff)) === 200, `staff(A) can read tenant-A ${ent.key} by id`);
+
+ // ISOLATION — staff(A) must NOT read B's record, nor see it in the list
+ const directB = await hit(`${ent.p}/${B.id}`, cookies.staff);
+ ok(directB === 404 || directB === 403, `staff(A) CANNOT read tenant-B ${ent.key} by id`, `got ${directB} (expected 404/403)`);
+ ok(!listRows.some((r) => r?.id === B.id), `staff(A) list does NOT leak tenant-B ${ent.key}`, 'B record visible to A');
+
+ // cleanup both
+ await fetch(`${BASE}${ent.p}/${A.id}`, { method: 'DELETE', headers: hdr(tenantA), redirect: 'manual' }).catch(() => {});
+ await fetch(`${BASE}${ent.p}/${B.id}`, { method: 'DELETE', headers: hdr(tenantB), redirect: 'manual' }).catch(() => {});
}
console.log(`\n=== ${pass} passed, ${fail} failed ===`);
← f7e95d8 test(cycle7): authorization-matrix + tenant-isolation suite
·
back to Norma
·
chore: refactor (drop dead testpulse login in api-smoke) + v efc419d →