[object Object]

← 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

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 →