Testing Authorization Systematically

Authorization bugs are invisible to scanners, so the only reliable detection is a test matrix you generate.

A scanner can find SQL injection because a payload produces a distinctive response. It cannot find BOLA, because it has no idea that invoice 1044 belongs to someone else. Authorization is the one area where automated security tooling does not help you — and therefore the one that most needs tests you write.

The matrix

Every test is a triple: actor × action × object. Enumerate them and the gaps become obvious.

ActorOwn objectOther user'sOther tenant'sAdmin action
Anonymous401401401401
User200404404403
Read-only user200 read / 403 write404404403
Other-tenant admin404200403 here
Admin200200 (audited)404200

Generate it, do not hand-write it

Hand-written authorization tests cover the routes someone remembered. Generate the matrix from your route inventory so a route added on Friday is covered on Friday.

Assert the side effect, not only the status

A handler can return 404 after performing the write. Assert that the row is unchanged, not merely that the response looked right.

Fixtures that make it possible

You need at least: two users in one tenant, one user in another tenant, one admin, one read-only account, and one object of each type owned by each. Without a second tenant in your test database, isolation bugs are undetectable.

The other paths

Run the same matrix against exports, search, webhooks and GraphQL. That is where the bugs are, because those paths were added after the REST route was reviewed.

Example

Example · javascript
// The fixture set that makes authorization testable at all
export async function seedAuthzFixtures() {
  const acme = await createTenant('acme');
  const globex = await createTenant('globex');

  return {
    acme, globex,
    alice:  await createUser(acme,   { role: 'user' }),
    bob:    await createUser(acme,   { role: 'user' }),      // same tenant
    carol:  await createUser(globex, { role: 'user' }),      // other tenant
    viewer: await createUser(acme,   { role: 'viewer' }),    // read-only
    admin:  await createUser(acme,   { role: 'admin' }),
    gAdmin: await createUser(globex, { role: 'admin' }),     // other tenant admin
  };
}
// Six accounts across two tenants. Everything else is generated from them.

When to use it

  • A generated matrix catches a new bulk endpoint that returns other users' records, on the day it is merged.
  • A test asserting the row is unchanged catches a handler that deletes before checking ownership and then returns 404.
  • Running the same matrix against the CSV export reveals it ignores the tenant filter the REST endpoint applies.

More examples

The generated matrix

The gAdmin row is the one that finds real bugs: an administrator with genuine privileges, in the wrong tenant, is a case hand-written tests almost never cover.

Example · javascript
import routes from '../route-inventory.json' with { type: 'json' };

// actor × expectation, per relationship to the object
const MATRIX = [
  { actor: 'anonymous',   own: 401, otherUser: 401, otherTenant: 401 },
  { actor: 'alice',       own: [200, 204], otherUser: 404, otherTenant: 404 },
  { actor: 'viewer',      own: { read: 200, write: 403 }, otherUser: 404, otherTenant: 404 },
  { actor: 'admin',       own: [200, 204], otherUser: [200, 204], otherTenant: 404 },
  { actor: 'gAdmin',      own: 404, otherUser: 404, otherTenant: [200, 204] },
];

const objectRoutes = routes.filter((r) => r.takesId);

describe('authorization matrix', () => {
  let fx, objects;
  beforeAll(async () => {
    fx = await seedAuthzFixtures();
    // One object of each type owned by each of alice / bob / carol
    objects = await seedObjectsForEach(fx);
  });

  for (const route of objectRoutes) {
    const resource = route.path.split('/')[2];
    const isWrite = route.method !== 'GET';

    for (const row of MATRIX) {
      for (const [relation, owner] of [
        ['own', 'alice'], ['otherUser', 'bob'], ['otherTenant', 'carol'],
      ]) {
        it(`${route.method} ${route.path} · ${row.actor} · ${relation}`, async () => {
          const id = objects[owner][resource];
          if (!id) return;

          const before = await db(resource).where({ id }).first();

          const req = request(app)[route.method.toLowerCase()](
            route.path.replace(/:\w+/, id));
          if (row.actor !== 'anonymous') {
            req.set('Authorization', `Bearer ${tokenFor(fx[row.actor])}`);
          }
          const res = await req.send(isWrite ? { name: 'changed' } : undefined);

          let expected = row[relation];
          if (expected && expected.read !== undefined) {
            expected = isWrite ? expected.write : expected.read;
          }
          expect([].concat(expected)).toContain(res.status);

          // The assertion most suites omit: a rejected write must not have
          // happened. A 404 returned AFTER the update is still a breach.
          if (isWrite && ![200, 204, 201].includes(res.status)) {
            const after = await db(resource).where({ id }).first();
            expect(after).toEqual(before);
          }
        });
      }
    }
  }
});

Covering the other paths to the same data

Exports top the list because they are usually written once, quickly, by someone reusing a query that predates the authorization model.

Example · javascript
// The REST route is reviewed. These are not, and they return the same rows.

describe('secondary data paths respect authorization', () => {
  it('CSV export contains only the caller\'s rows', async () => {
    const csv = await request(app).get('/api/invoices/export')
      .set('Authorization', `Bearer ${aliceToken}`).expect(200);

    const ids = parseCsv(csv.text).map((r) => r.id);
    const foreign = await db('invoices')
      .whereIn('id', ids).whereNot({ user_id: alice.id });
    expect(foreign).toHaveLength(0);
  });

  it('search results are scoped to the tenant', async () => {
    await reindexAll();
    const res = await request(app).get('/api/search?q=invoice')
      .set('Authorization', `Bearer ${aliceToken}`).expect(200);

    expect(res.body.hits.every((h) => h.tenantId === acme.id)).toBe(true);
  });

  it('GraphQL resolvers enforce the same rules as REST', async () => {
    const res = await request(app).post('/graphql')
      .set('Authorization', `Bearer ${aliceToken}`)
      .send({ query: `{ invoice(id: "${bobInvoice.id}") { id total } }` });

    expect(res.body.data.invoice).toBeNull();
    // And it must not leak existence through the error message either.
    expect(JSON.stringify(res.body.errors ?? '')).not.toMatch(/forbidden|denied/i);
  });

  it('webhook payloads contain only the subscribing tenant\'s data', async () => {
    const deliveries = await captureWebhookDeliveries(() => createInvoice(acme));
    for (const d of deliveries) {
      expect(d.payload.tenantId).toBe(d.subscription.tenantId);
    }
  });

  it('the admin panel uses the same authorization as the API', async () => {
    const res = await request(app).get(`/admin/invoices/${globexInvoice.id}`)
      .set('Authorization', `Bearer ${acmeAdminToken}`);
    expect(res.status).toBe(404);      // admin of acme, invoice of globex
  });
});

// Ordered by how often each one is actually broken:
//   1. exports   2. search indexes   3. GraphQL   4. webhooks   5. admin panels

Continuous verification against a live environment

Post-deploy smoke tests catch what unit tests structurally cannot: an environment variable, a proxy rule, or a feature flag that differs in the real environment.

Example · bash
#!/usr/bin/env bash
# scripts/authz-smoke.sh — run against staging after every deploy.
# Unit tests can pass while configuration diverges; this checks reality.
set -uo pipefail

BASE="${BASE:-https://staging.dfg.com}"
FAIL=0

check() {  # check <expected> <description> <curl args...>
  local expected="$1" desc="$2"; shift 2
  local code; code=$(curl -s -o /dev/null -w '%{http_code}' "$@")
  if [ "$code" != "$expected" ]; then
    echo "FAIL  [$code, want $expected]  $desc"; FAIL=1
  else
    echo "ok    [$code]  $desc"
  fi
}

# BOLA
check 404 "alice cannot read bob's invoice" \
  "$BASE/api/invoices/$BOB_INVOICE" -H "Authorization: Bearer $ALICE"
check 404 "alice cannot delete bob's invoice" \
  -X DELETE "$BASE/api/invoices/$BOB_INVOICE" -H "Authorization: Bearer $ALICE"

# Cross-tenant
check 404 "acme cannot read globex's invoice" \
  "$BASE/api/invoices/$GLOBEX_INVOICE" -H "Authorization: Bearer $ALICE"

# BFLA
check 403 "normal user cannot reach admin routes" \
  "$BASE/api/admin/users" -H "Authorization: Bearer $ALICE"
check 401 "admin routes reject anonymous" "$BASE/api/admin/users"

# Mass assignment — check the EFFECT, not the status
curl -s -X PATCH "$BASE/api/users/me" -H "Authorization: Bearer $ALICE" \
  -H 'Content-Type: application/json' -d '{"role":"admin"}' > /dev/null
role=$(curl -s "$BASE/api/users/me" -H "Authorization: Bearer $ALICE" | jq -r .role)
[ "$role" = "admin" ] && { echo "FAIL  mass assignment: role became admin"; FAIL=1; } \
                      || echo "ok    mass assignment rejected"

# Resource limits
n=$(curl -s "$BASE/api/invoices?limit=1000000" -H "Authorization: Bearer $ALICE" | jq 'length')
[ "$n" -gt 100 ] && { echo "FAIL  page size not capped ($n)"; FAIL=1; } \
                 || echo "ok    page size capped at $n"

exit $FAIL

# Wire it into the deploy pipeline as a gate. It takes six seconds and it has
# caught configuration drift that every unit test missed.

Discussion

  • Be the first to comment on this lesson.