Skip to content

Commit 654e93c

Browse files
committed
docs: add RBAC middleware example
Row-level security = injected filter. Column-level security = stripped projections. No permission tables, no policy engine — security is just query modification in a Worker middleware.
1 parent 7dc380c commit 654e93c

1 file changed

Lines changed: 121 additions & 0 deletions

File tree

examples/rbac-middleware.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/**
2+
* RBAC middleware example — row-level and column-level security via filter injection.
3+
*
4+
* Security isn't a feature to build. It's a filter to inject.
5+
*
6+
* This Worker middleware authenticates the request, then modifies the query
7+
* descriptor before it reaches QueryDO. Each tenant only sees their own data.
8+
* Sensitive columns are stripped for non-admin roles.
9+
*
10+
* Works with:
11+
* - Cloudflare Access (JWT in cf-access-jwt-assertion header)
12+
* - Any JWT issuer (Auth0, Clerk, Supabase Auth, etc.)
13+
* - API keys (look up role from D1/KV)
14+
*/
15+
16+
// ─── Types ───────────────────────────────────────────────────────────────
17+
18+
interface User {
19+
id: string;
20+
tenantId: string;
21+
role: "admin" | "analyst" | "viewer";
22+
}
23+
24+
interface Env {
25+
QUERY_DO: DurableObjectNamespace;
26+
// Optional: store API keys and roles
27+
// AUTH_KV: KVNamespace;
28+
}
29+
30+
// ─── Column policies ────────────────────────────────────────────────────
31+
32+
/** Columns hidden from non-admin roles */
33+
const RESTRICTED_COLUMNS: Record<string, string[]> = {
34+
viewer: ["revenue", "cost", "margin", "salary", "ssn", "email"],
35+
analyst: ["ssn", "salary"],
36+
admin: [], // admins see everything
37+
};
38+
39+
// ─── Auth ────────────────────────────────────────────────────────────────
40+
41+
/** Extract user from JWT. Replace with your auth provider's verification. */
42+
async function authenticate(request: Request): Promise<User> {
43+
// Cloudflare Access: JWT is in cf-access-jwt-assertion header
44+
const jwt = request.headers.get("cf-access-jwt-assertion")
45+
?? request.headers.get("authorization")?.replace("Bearer ", "");
46+
47+
if (!jwt) throw new Error("No auth token");
48+
49+
// In production: verify JWT signature against your JWKS endpoint
50+
// const payload = await verifyJwt(jwt, env.JWKS_URL);
51+
// For this example, decode without verification:
52+
const payload = JSON.parse(atob(jwt.split(".")[1]));
53+
54+
return {
55+
id: payload.sub,
56+
tenantId: payload.tenant_id ?? payload.org_id ?? "default",
57+
role: payload.role ?? "viewer",
58+
};
59+
}
60+
61+
// ─── Middleware ──────────────────────────────────────────────────────────
62+
63+
export default {
64+
async fetch(request: Request, env: Env): Promise<Response> {
65+
// 1. Authenticate
66+
let user: User;
67+
try {
68+
user = await authenticate(request);
69+
} catch {
70+
return new Response(JSON.stringify({ error: "Unauthorized" }), {
71+
status: 401,
72+
headers: { "Content-Type": "application/json" },
73+
});
74+
}
75+
76+
// 2. Parse the query descriptor from the request body
77+
const body = await request.json() as { descriptor: Record<string, unknown> };
78+
const descriptor = body.descriptor;
79+
80+
// 3. Row-level security: inject tenant filter
81+
// Every query automatically scoped to the user's tenant.
82+
if (user.role !== "admin") {
83+
const filters = (descriptor.filters ?? []) as { column: string; op: string; value: unknown }[];
84+
filters.push({ column: "tenant_id", op: "eq", value: user.tenantId });
85+
descriptor.filters = filters;
86+
}
87+
88+
// 4. Column-level security: strip restricted columns from projections
89+
const restricted = new Set(RESTRICTED_COLUMNS[user.role] ?? []);
90+
if (restricted.size > 0) {
91+
const projections = descriptor.projections as string[] | undefined;
92+
if (projections && projections.length > 0) {
93+
descriptor.projections = projections.filter((c: string) => !restricted.has(c));
94+
}
95+
}
96+
97+
// 5. Forward to QueryDO — the query runs with security filters baked in
98+
const doId = env.QUERY_DO.idFromName("default");
99+
const queryDo = env.QUERY_DO.get(doId);
100+
const result = await (queryDo as unknown as { queryRpc(d: unknown): Promise<unknown> })
101+
.queryRpc(descriptor);
102+
103+
return new Response(JSON.stringify(result), {
104+
headers: {
105+
"Content-Type": "application/json",
106+
"X-QueryMode-User": user.id,
107+
"X-QueryMode-Tenant": user.tenantId,
108+
},
109+
});
110+
},
111+
};
112+
113+
// ─── What this gives you ─────────────────────────────────────────────────
114+
//
115+
// 1. Row-level security = injected filter (tenant_id = user's tenant)
116+
// 2. Column-level security = stripped projections (no salary/ssn for viewers)
117+
// 3. Audit trail = add env.AUDIT_LOG.writeDataPoint() before the query
118+
// 4. Rate limiting = add env.RATE_LIMITER.check() before the query
119+
//
120+
// No permission tables. No GRANT/REVOKE. No policy engine.
121+
// Security is just filters and projections — things QueryMode already does.

0 commit comments

Comments
 (0)