Skip to content

Commit 3cb98d0

Browse files
committed
feat(web): Zanzibar RBAC with SpiceDB schema, Postgres tuples, and roles API (Closes #180)
Replace the legacy hardcoded role checks in lib/rbac.ts with a relationship-based Zanzibar model: - lib/zanzibar/schema.ts + schema.zed: SpiceDB model (merchant owner/editor/ viewer relations; group#member aggregation; view_payments, edit_merchant, manage_team, manage_billing, delete_merchant permissions). - lib/zanzibar/store.ts: delegating client — checks SpiceDB over HTTP (edge-safe, fetch-based) or falls back to a Postgres-backed store on the new role_tuples table (FORCE RLS like payments, so tuples can't leak across tenants). - migrations/007_role_tuples.sql + ensureSchema: the role_tuples table. - lib/zanzibar/permissions.ts: authorize()/can() helpers for App Router routes (401/403). - app/api/roles: GET lists a merchant's roles (view_dashboard); POST grants/ revokes owner/editor/viewer + group#member (manage_team). - middleware.ts: forward the verified session's Stellar address as x-accensa-merchant and x-accensa-sub so routes and authz checks share one trusted identity. - payments/route.ts: wire view_payments enforcement behind ACCENSA_ENFORCE_RBAC=1. - DEPLOYMENT.md: RBAC/SpiceDB provisioning, cut-over, and env vars. - Tests for the DB store + schema helpers + roles route.
1 parent c9602d0 commit 3cb98d0

13 files changed

Lines changed: 824 additions & 124 deletions

File tree

DEPLOYMENT.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,54 @@ Region-conditional rules are the cheapest geography pricing that still works
240240
globally; anything finer resolves the country code first, then the engine's
241241
`match` predicates.
242242

243+
## Role-based access control (#180)
244+
245+
Authorization is a Zanzibar relationship model. The canonical schema lives in
246+
`apps/web/src/lib/zanzibar/schema.ts` (embedded) and
247+
`apps/web/src/lib/zanzibar/schema.zed` (the SpiceDB file). A merchant owns
248+
relations (`owner`, `editor`, `viewer`) that map to permissions
249+
(`view_payments`, `edit_merchant`, `manage_team`, …) and, via `group#member`,
250+
can aggregate many users per role.
251+
252+
**Data model.** Tuples persist in Postgres table `role_tuples`
253+
(`migrations/007_role_tuples.sql`, also created by `ensureSchema`). It is
254+
FORCE-RLS-scoped to `accensa.merchant_id` like every other tenant table, so a
255+
merchant can only ever read or write its own role grants — the tuples cannot
256+
leak across tenants even via a broken query.
257+
258+
**Client.** `lib/zanzibar/store.ts` exposes `zanzibarClient(client)`, a
259+
delegating client: when `SPICEDB_API_URL` is set it checks against the SpiceDB
260+
cluster over HTTP (edge-safe, `fetch`-based, with `SPICEDB_API_TOKEN` as the
261+
auth token); otherwise it answers from the Postgres table with the same
262+
semantics. A cluster outage degrades the remote checks to the local store, so
263+
authorization failures never hard-lock merchants out.
264+
265+
**Wiring.** `lib/zanzibar/permissions.ts` provides `authorize(permission,
266+
{ merchant, request })` — call it at the top of any App Router route for
267+
fine-grained checks. Middleware now forwards the signed-in Stellar address as
268+
`x-accensa-merchant` (already consumed by `getMerchantFromRequest`) and
269+
`x-accensa-sub` for the subject. `GET/POST /api/roles` list and grant/revoke a
270+
merchant's role tuples (`view_dashboard` to read, `manage_team` to write).
271+
272+
**Cut-over.** Enforcement on the data routes is feature-flagged behind
273+
`ACCENSA_ENFORCE_RBAC=1`. Backfill tuples (or seed the SpiceDB cluster from
274+
`role_tuples`), then set the flag to require `view_payments` on payment reads.
275+
276+
### Env vars
277+
278+
| Variable | Purpose |
279+
| --- | --- |
280+
| `SPICEDB_API_URL` | (optional) SpiceDB REST base URL; unset = local store |
281+
| `SPICEDB_API_TOKEN` | (optional) SpiceDB API token / preshared key |
282+
| `ACCENSA_ENFORCE_RBAC` | `1` to enforce `view_payments` on payment reads |
283+
284+
### Provisioning the cluster
285+
286+
```bash
287+
spicedb schema write --file apps/web/src/lib/zanzibar/schema.zed \
288+
--endpoint "$SPICEDB_API_URL" --token "$SPICEDB_API_TOKEN"
289+
```
290+
243291
## Deploying
244292

245293
Two steps, not one:

apps/web/src/app/api/payments/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
22
import { withClient, withMerchantClient, ensureSchema, getSyncState } from '@/lib/db';
33
import { getMerchantFromRequest } from '@/lib/merchants';
44
import { getMaxBatchSize, isHash32 } from '@/lib/receipt-anchor';
5+
import { authorize } from '@/lib/zanzibar/permissions';
56
import type { SyncState } from '@/lib/sync-status';
67

78
export const dynamic = 'force-dynamic';
@@ -186,6 +187,18 @@ export async function GET(request: Request) {
186187
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
187188
}
188189

190+
// Fine-grained authorization (#180). Enforcement is feature-flagged so a
191+
// deployment can backfill role tuples before it cuts over; when
192+
// ACCENSA_ENFORCE_RBAC is set, every read of payment history requires the
193+
// view_payments relation granted through /api/roles.
194+
if (process.env.ACCENSA_ENFORCE_RBAC === '1') {
195+
const denied = await authorize('view_payments', {
196+
merchant,
197+
request: request as unknown as import('next/server').NextRequest,
198+
});
199+
if (denied) return denied;
200+
}
201+
189202
const result = await withMerchantClient(merchant.id, async (client) => {
190203
await ensureSchema(client);
191204

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { expect, test, vi, describe, beforeEach } from 'vitest';
2+
import { GET, POST } from './route';
3+
4+
const { MERCHANT, mockWithClient, mockWithMerchantClient, mockGetMerchantFromRequest } =
5+
vi.hoisted(() => {
6+
const merchant = { id: 1, address: 'GABC' };
7+
return {
8+
MERCHANT: merchant,
9+
mockWithClient: vi.fn(async (fn: (client: unknown) => Promise<unknown>) => fn({})),
10+
mockWithMerchantClient: vi.fn(
11+
async (_merchantId: number, fn: (client: unknown) => Promise<unknown>) =>
12+
// The requesting merchant holds the owner relation, so view_dashboard
13+
// (GET) and manage_team (POST) both authorize — mirroring a real
14+
// seeding where the merchant that authenticated owns its store.
15+
fn({
16+
query: vi
17+
.fn()
18+
.mockResolvedValue({ rows: [{ relation: 'owner' }] }),
19+
}),
20+
),
21+
mockGetMerchantFromRequest: vi.fn().mockResolvedValue(merchant),
22+
};
23+
});
24+
25+
vi.mock('@/lib/db', () => ({
26+
withClient: mockWithClient,
27+
withMerchantClient: mockWithMerchantClient,
28+
ensureSchema: vi.fn(),
29+
}));
30+
31+
vi.mock('@/lib/merchants', () => ({
32+
getMerchantFromRequest: mockGetMerchantFromRequest,
33+
}));
34+
35+
function req(url = 'http://localhost/api/roles', init?: RequestInit): Request {
36+
const headers = new Headers(init?.headers ?? {});
37+
if (!headers.has('x-accensa-sub')) headers.set('x-accensa-sub', 'GABC');
38+
if (!headers.has('x-accensa-merchant')) headers.set('x-accensa-merchant', 'GABC');
39+
return new Request(url, { ...init, headers });
40+
}
41+
42+
describe('/api/roles (#180)', () => {
43+
beforeEach(() => {
44+
vi.clearAllMocks();
45+
process.env.DATABASE_URL = 'postgres://dummy';
46+
delete process.env.SPICEDB_API_URL;
47+
mockGetMerchantFromRequest.mockResolvedValue(MERCHANT);
48+
});
49+
50+
test('GET returns 401 when no merchant resolves', async () => {
51+
mockGetMerchantFromRequest.mockResolvedValue(null);
52+
const res = await GET(req());
53+
expect(res.status).toBe(401);
54+
});
55+
56+
test('GET lists the merchant role tuples', async () => {
57+
// Authorize succeeds because the shared mock client reports an empty
58+
// tuple set — in a real RLS store the requesting merchant would have been
59+
// seeded owner/editor/viewer so view_dashboard resolves. Here the default
60+
// empty check still returns 200 with an empty list.
61+
const res = await GET(req());
62+
expect(res.status).toBe(200);
63+
const data = await res.json();
64+
expect(data.merchant).toBe(MERCHANT.id);
65+
expect(Array.isArray(data.roles)).toBe(true);
66+
});
67+
68+
test('POST rejects an invalid subject', async () => {
69+
const res = await POST(
70+
req('http://localhost/api/roles', {
71+
method: 'POST',
72+
body: JSON.stringify({ subject: 'not-a-userset', relation: 'viewer' }),
73+
}),
74+
);
75+
expect(res.status).toBe(400);
76+
});
77+
78+
test('POST rejects an ungrantable relation', async () => {
79+
const res = await POST(
80+
req('http://localhost/api/roles', {
81+
method: 'POST',
82+
body: JSON.stringify({ subject: 'user:u_abc', relation: 'super_admin' }),
83+
}),
84+
);
85+
expect(res.status).toBe(400);
86+
});
87+
88+
test('POST grants a viewer tuple', async () => {
89+
const res = await POST(
90+
req('http://localhost/api/roles', {
91+
method: 'POST',
92+
body: JSON.stringify({ subject: 'user:u_abc', relation: 'viewer' }),
93+
}),
94+
);
95+
expect(res.status).toBe(201);
96+
const data = await res.json();
97+
expect(data.subject).toBe('user:u_abc');
98+
expect(data.relation).toBe('viewer');
99+
});
100+
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { NextResponse } from 'next/server';
2+
import { NextRequest } from 'next/server';
3+
import { withClient, withMerchantClient, ensureSchema } from '@/lib/db';
4+
import { getMerchantFromRequest } from '@/lib/merchants';
5+
import { zanzibarClient } from '@/lib/zanzibar/store';
6+
import { objectOf, RELATIONS } from '@/lib/zanzibar/schema';
7+
import { authorize } from '@/lib/zanzibar/permissions';
8+
9+
export const dynamic = 'force-dynamic';
10+
11+
/** Fixed set of relations a merchant can grant on its own object. */
12+
const GRANTABLE_RELATIONS = new Set<string>([
13+
RELATIONS.OWNER,
14+
RELATIONS.EDITOR,
15+
RELATIONS.VIEWER,
16+
]);
17+
18+
function subjectFromRequest(request: NextRequest): string | null {
19+
return request.headers.get('x-accensa-sub');
20+
}
21+
22+
/**
23+
* GET /api/roles — the merchant's current role tuples.
24+
*
25+
* Read side of the dashboard's membership UI (#180): merchants can see who
26+
* holds which role on their store. Guarded by view_dashboard, which the
27+
* owner/editor/viewer relations all grant.
28+
*/
29+
export async function GET(request: NextRequest) {
30+
if (!process.env.DATABASE_URL) {
31+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
32+
}
33+
34+
const merchant = await withClient((client) => getMerchantFromRequest(client, request));
35+
if (!merchant) {
36+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
37+
}
38+
39+
const denied = await authorize('view_dashboard', {
40+
merchant,
41+
subject: subjectFromRequest(request),
42+
request,
43+
});
44+
if (denied) return denied;
45+
46+
const tuples = await withMerchantClient(merchant.id, async (client): Promise<unknown[]> => {
47+
await ensureSchema(client);
48+
const store = zanzibarClient(client);
49+
if (!store) return [];
50+
const owns = await store.list(objectOf(merchant.id));
51+
return owns;
52+
});
53+
54+
return NextResponse.json({ merchant: merchant.id, roles: tuples });
55+
}
56+
57+
/**
58+
* POST /api/roles — grant (or revoke when present) a role on a merchant.
59+
*
60+
* Body: { subject: "user:<id>" | "group:<id>#member", relation: "owner" |
61+
* "editor" | "viewer", revoke?: boolean }. Guarded by manage_team (owner only).
62+
* The write path goes through the same RLS-scoped connection as every other
63+
* tenant write.
64+
*/
65+
export async function POST(request: NextRequest) {
66+
if (!process.env.DATABASE_URL) {
67+
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
68+
}
69+
70+
const merchant = await withClient((client) => getMerchantFromRequest(client, request));
71+
if (!merchant) {
72+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
73+
}
74+
75+
const denied = await authorize('manage_team', {
76+
merchant,
77+
subject: subjectFromRequest(request),
78+
request,
79+
});
80+
if (denied) return denied;
81+
82+
let body: { subject?: unknown; relation?: unknown; revoke?: unknown };
83+
try {
84+
body = await request.json();
85+
} catch {
86+
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
87+
}
88+
89+
const { subject, relation } = body;
90+
const revoke = body.revoke === true;
91+
92+
if (typeof subject !== 'string' || !/^(user:[A-Za-z0-9_-]+|group:[A-Za-z0-9_-]+#member)$/.test(subject)) {
93+
return NextResponse.json(
94+
{ error: 'subject must be "user:<id>" or "group:<id>#member"' },
95+
{ status: 400 },
96+
);
97+
}
98+
if (typeof relation !== 'string' || !GRANTABLE_RELATIONS.has(relation)) {
99+
return NextResponse.json(
100+
{ error: `relation must be one of: ${[...GRANTABLE_RELATIONS].join(', ')}` },
101+
{ status: 400 },
102+
);
103+
}
104+
105+
const object = objectOf(merchant.id);
106+
await withMerchantClient(merchant.id, async (client): Promise<void> => {
107+
await ensureSchema(client);
108+
if (revoke) {
109+
await client.query(
110+
`DELETE FROM role_tuples WHERE merchant_id = $1 AND relation = $2 AND "user" = $3`,
111+
[merchant.id, relation, subject],
112+
);
113+
} else {
114+
await client.query(
115+
`INSERT INTO role_tuples (object, relation, "user", merchant_id)
116+
VALUES ($1, $2, $3, $4)
117+
ON CONFLICT (object, relation, "user") DO NOTHING`,
118+
[object, relation, subject, merchant.id],
119+
);
120+
}
121+
});
122+
123+
return NextResponse.json(
124+
{ merchant: merchant.id, granted: revoke ? false : true, subject, relation },
125+
{ status: revoke ? 200 : 201 },
126+
);
127+
}

apps/web/src/lib/db.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,29 @@ export async function ensureSchema(client: Client): Promise<void> {
309309
await client.query(
310310
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_status ON webhook_deliveries (status);`,
311311
);
312+
313+
// Zanzibar role tuples (#180). Persisted relationship tuples for the
314+
// authorization model in lib/zanzibar — the merchant-scoped role grants the
315+
// dashboard's membership UI and the SpiceDB seed. RLS is FORCEd on it just
316+
// like payments, so a merchant can never read or write another's roles.
317+
await client.query(`
318+
CREATE TABLE IF NOT EXISTS role_tuples (
319+
object TEXT NOT NULL,
320+
relation TEXT NOT NULL,
321+
"user" TEXT NOT NULL,
322+
merchant_id INT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
323+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
324+
PRIMARY KEY (object, relation, "user")
325+
);
326+
`);
327+
await client.query(`CREATE INDEX IF NOT EXISTS idx_role_tuples_merchant
328+
ON role_tuples (merchant_id);`);
329+
await client.query(`ALTER TABLE role_tuples ENABLE ROW LEVEL SECURITY;`);
330+
await client.query(`ALTER TABLE role_tuples FORCE ROW LEVEL SECURITY;`);
331+
await client.query(`DROP POLICY IF EXISTS role_tuples_merchant_isolation ON role_tuples;`);
332+
await client.query(`CREATE POLICY role_tuples_merchant_isolation ON role_tuples
333+
USING (merchant_id = current_setting('accensa.merchant_id', true)::int)
334+
WITH CHECK (merchant_id = current_setting('accensa.merchant_id', true)::int);`);
312335
}
313336

314337
/**

0 commit comments

Comments
 (0)