[object Object]

← back to Norma Platform

test(cycle7): authorization-matrix + tenant-isolation suite (real tenant fixtures)

f7e95d81c637ec91114bb987c050c40fa3d705c5 · 2026-08-06 14:06:11 -0700 · Steve

test:authz (tests/api-authz-matrix.mjs) — the security coverage the null-org fixtures
couldn't give. Self-provisions two tenant accounts in sdcc_test and scopes teststaff to
tenant A BEFORE login (org_id is baked into the session token at login).

1) ROLE MATRIX: derives each GET route's allowed roles from its requireRole(...) call
   (resolving ...VAR spreads to their local const) and asserts admin/staff/pulse each get
   the right access across 179 guarded routes. 0 under-restrictions (no disallowed role
   ever bypasses 403 = no privilege-escalation) + 0 over-restrictions.
2) TENANT ISOLATION: admin creates a tenant-B-owned donation + grant; asserts staff scoped
   to tenant A CANNOT read them by id (404/403) and they never leak into staff's list.

Parser fix: ...ROLES is NOT always all roles — email-analyzer files define a local
const ROLES=['admin','staff']; the resolver reads the local const so pulse's correct 403
isn't miscounted as over-restriction.

Full battery (6 suites): api 195 / write 216 / crud 35 / authz 8 / regression 20 / e2e 9.

Files touched

Diff

commit f7e95d81c637ec91114bb987c050c40fa3d705c5
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Aug 6 14:06:11 2026 -0700

    test(cycle7): authorization-matrix + tenant-isolation suite (real tenant fixtures)
    
    test:authz (tests/api-authz-matrix.mjs) — the security coverage the null-org fixtures
    couldn't give. Self-provisions two tenant accounts in sdcc_test and scopes teststaff to
    tenant A BEFORE login (org_id is baked into the session token at login).
    
    1) ROLE MATRIX: derives each GET route's allowed roles from its requireRole(...) call
       (resolving ...VAR spreads to their local const) and asserts admin/staff/pulse each get
       the right access across 179 guarded routes. 0 under-restrictions (no disallowed role
       ever bypasses 403 = no privilege-escalation) + 0 over-restrictions.
    2) TENANT ISOLATION: admin creates a tenant-B-owned donation + grant; asserts staff scoped
       to tenant A CANNOT read them by id (404/403) and they never leak into staff's list.
    
    Parser fix: ...ROLES is NOT always all roles — email-analyzer files define a local
    const ROLES=['admin','staff']; the resolver reads the local const so pulse's correct 403
    isn't miscounted as over-restriction.
    
    Full battery (6 suites): api 195 / write 216 / crud 35 / authz 8 / regression 20 / e2e 9.
---
 package.json               |   3 +-
 tests/api-authz-matrix.mjs | 174 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 176 insertions(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 8d39c9e..0f097c8 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,8 @@
     "test:smoke": "node tests/api-smoke.mjs --smoke",
     "test:write": "node tests/api-write-smoke.mjs",
     "test:e2e": "playwright test",
-    "test:crud": "node tests/api-crud-lifecycle.mjs"
+    "test:crud": "node tests/api-crud-lifecycle.mjs",
+    "test:authz": "node tests/api-authz-matrix.mjs"
   },
   "dependencies": {
     "@codemirror/lang-html": "^6.4.11",
diff --git a/tests/api-authz-matrix.mjs b/tests/api-authz-matrix.mjs
new file mode 100644
index 0000000..0c615d5
--- /dev/null
+++ b/tests/api-authz-matrix.mjs
@@ -0,0 +1,174 @@
+#!/usr/bin/env node
+/**
+ * Norma authorization-matrix + tenant-isolation suite. Two security properties the
+ * earlier suites (which ran with null-org fixtures) could not cover:
+ *
+ *  1) ROLE MATRIX — for every GET route, derive the allowed roles from its
+ *     requireRole(request, ...) call, then assert each seeded role gets the right
+ *     access: a DISALLOWED role must get 403 (an under-restriction = privilege-
+ *     escalation hole = hard fail); an ALLOWED role must NOT get 403.
+ *  2) TENANT ISOLATION — a staff user scoped to tenant A must not be able to read
+ *     a record owned by tenant B (org-scoped data separation).
+ *
+ * Self-provisions fixtures in sdcc_test (two tenant accounts; teststaff → tenant A)
+ * BEFORE login, because the session token bakes org_id at login time. Scratch DB only
+ * — NEVER live :7400. Requires seeded admin/teststaff/testpulse (pw TestPass123!).
+ *
+ *   bash scripts/test-instance.sh
+ *   npm run test:authz
+ */
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath as f2p } from 'node:url';
+import pg from 'pg';
+
+const BASE = process.env.NORMA_TEST_URL || 'http://127.0.0.1:7411';
+const PW = process.env.NORMA_TEST_PW || 'TestPass123!';
+const TEST_DB = process.env.NORMA_TEST_DB || 'postgresql://127.0.0.1:5432/sdcc_test';
+const CONCURRENCY = 8;
+const NIL = '00000000-0000-0000-0000-000000000000';
+const SEG = { '[id]': NIL, '[type]': 'org', '[slug]': 'smoke', '[zip]': '90210' };
+const ROLES = ['admin', 'staff', 'pulse'];
+
+const ROOT = join(dirname(f2p(import.meta.url)), '..');
+const API_DIR = join(ROOT, 'app', 'api');
+
+let pass = 0, fail = 0;
+const fails = [];
+function ok(cond, name, detail = '') {
+  if (cond) { pass++; }
+  else { fail++; fails.push(`${name}${detail ? ' — ' + detail : ''}`); }
+}
+
+// ── Derive each GET route's allowed roles from its requireRole(...) call ───────
+function getRouteAuthz(dir, rel = '') {
+  const out = [];
+  for (const name of readdirSync(dir)) {
+    const abs = join(dir, name);
+    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 });
+  }
+  return out;
+}
+
+async function login(username) {
+  const res = await fetch(`${BASE}/api/auth/login`, {
+    method: 'POST', headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password: PW }), redirect: 'manual',
+  });
+  return (res.headers.get('set-cookie') || '').match(/norma-auth=([^;]+)/)?.[1];
+}
+async function hit(path, cookie, extra = {}) {
+  try {
+    const r = await fetch(`${BASE}${path}`, { headers: { ...(cookie ? { Cookie: `norma-auth=${cookie}` } : {}), ...extra }, redirect: 'manual' });
+    return r.status;
+  } catch (e) { return `ERR:${e.code || e.message}`; }
+}
+async function pool(items, worker) {
+  const res = new Array(items.length); let i = 0;
+  await Promise.all(Array.from({ length: CONCURRENCY }, async () => {
+    while (i < items.length) { const idx = i++; res[idx] = await worker(items[idx]); }
+  }));
+  return res;
+}
+
+// ── Provision two tenants + scope teststaff to tenant A (BEFORE login) ─────────
+async function provision() {
+  const c = new pg.Client({ connectionString: TEST_DB }); await c.connect();
+  const a = (await c.query('SELECT id FROM nonprofit_accounts ORDER BY created_at NULLS FIRST LIMIT 1')).rows[0]?.id
+        || (await c.query('SELECT id FROM nonprofit_accounts LIMIT 1')).rows[0]?.id;
+  // tenant B: reuse a marked clone if present, else clone tenant A with a new id
+  let b = (await c.query("SELECT id FROM nonprofit_accounts WHERE org_name = 'AUTHZ-Tenant-B' LIMIT 1")).rows[0]?.id;
+  if (!b) {
+    b = (await c.query(
+      `INSERT INTO nonprofit_accounts (id, org_name, contact_name, contact_email, password_hash)
+       VALUES (gen_random_uuid(), 'AUTHZ-Tenant-B', 'Authz B', 'authz-b@test.invalid', 'x')
+       RETURNING id`)).rows[0].id;
+  }
+  await c.query("UPDATE tier_credentials SET org_id = $1 WHERE username = 'teststaff'", [a]);
+  await c.end();
+  return { a, b };
+}
+
+async function main() {
+  console.log(`\n=== Norma authz-matrix + tenant-isolation — ${BASE} ===\n`);
+  const { a: tenantA, b: tenantB } = await provision();
+  console.log(`tenant A (teststaff): ${tenantA}\ntenant B (foreign):   ${tenantB}\n`);
+
+  const cookies = {};
+  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) => {
+    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]})`);
+    }
+  });
+  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)',
+     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(() => {});
+  }
+
+  console.log(`\n=== ${pass} passed, ${fail} failed ===`);
+  if (fail) { console.log('\nFAILURES:'); fails.forEach((f) => console.log(`  \x1b[31m✗\x1b[0m ${f}`)); }
+  else console.log('\x1b[32m✔ authz + isolation green\x1b[0m');
+  process.exit(fail ? 1 : 0);
+}
+
+main().catch((e) => { console.error('authz harness error:', e); process.exit(2); });

← 2a8a6c7 test(cycle6 Cody-gate): CRUD tests real org id-space; non-ho  ·  back to Norma Platform  ·  test(cycle7 Cody-gate): mutation role-matrix + isolation pos a945908 →