Skip to content

Commit ded15c3

Browse files
committed
feat(security): resolve current_user.email in RLS owner policies + showcase owner-scoped invoices
RLS `using` predicates can now reference `current_user.email` — a unique, seedable owner anchor (`owner = current_user.email`). The RLS compiler previously resolved only id/organization_id/roles/org_user_ids, so any owner-by-email predicate compiled to the deny sentinel (fail-closed → user saw nothing). Email is sourced from the auth session (bounded sys_user fallback for the API-key path) and threaded onto ExecutionContext in BOTH identity resolvers: the REST data path (rest-server) and the dispatcher path (resolve-execution-context). Display `name` is deliberately NOT exposed to RLS — names collide, and a collision on an ownership predicate is an access-control leak. Only unique identifiers (id, email) are resolvable; documented in the compiler + a unit test pair. Combined with controlled_by_parent (ADR-0055), a master's owner scoping now flows to its detail records over seed data (no per-user ids needed). The example-showcase demonstrates it: showcase_invoice carries an `owner` email + owner RLS, its lines are controlled-by-parent, and invoices/lines are seeded per owner. Also fixes the showcase's previously inert owner predicates (they used `==` and current_user.name — neither accepted by the compiler) to `= current_user.email`. Verified end-to-end through the real HTTP stack: a new showcase-invoice-seed-isolation dogfood proof asserts a contributor reads only their own invoices and (derived) their own lines, not another owner's. Full dogfood suite 10 files / 81 tests; plugin-security 104; rest 121; showcase 20. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx
1 parent 82e46c2 commit ded15c3

10 files changed

Lines changed: 282 additions & 3 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/runtime": patch
5+
"@objectstack/rest": patch
6+
"@objectstack/example-showcase": patch
7+
---
8+
9+
feat(security): resolve `current_user.email` in RLS owner policies
10+
11+
RLS `using` predicates can now reference **`current_user.email`** — a unique,
12+
human-readable, *seedable* owner anchor (`owner = current_user.email`). Previously
13+
the RLS compiler resolved only `current_user.id` / `organization_id` / `roles` /
14+
`org_user_ids`, so any owner-by-name/email predicate silently compiled to the
15+
deny sentinel (fail-closed → the user saw nothing). Email is sourced for free
16+
from the auth session (with a bounded `sys_user` fallback for the API-key path)
17+
and threaded onto the `ExecutionContext` in both identity resolvers — the REST
18+
data path (`rest-server`) and the dispatcher path (`resolve-execution-context`).
19+
20+
Display `name` is deliberately **not** exposed to RLS: names collide, and a
21+
collision on an ownership predicate is an access-control leak. Only unique
22+
identifiers (`id`, `email`) are resolvable.
23+
24+
This makes owner-scoped row-level security work with seed data (no per-user ids
25+
needed) and, combined with `controlled_by_parent` (ADR-0055), lets a master's
26+
owner scoping flow to its detail records. The example-showcase demonstrates it:
27+
`showcase_invoice` carries an `owner` email + an owner RLS policy, its lines are
28+
controlled-by-parent, and invoices/lines are seeded per owner. It also fixes the
29+
showcase's previously inert owner predicates (they used `==` and `current_user.name`,
30+
neither of which the compiler accepts) to `= current_user.email`.

examples/app-showcase/src/data/index.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Project } from '../objects/project.object.js';
77
import { Task } from '../objects/task.object.js';
88
import { Category } from '../objects/category.object.js';
99
import { Team, ProjectMembership } from '../objects/team.object.js';
10-
import { Product } from '../objects/invoice.object.js';
10+
import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js';
1111
import { FieldZoo } from '../objects/field-zoo.object.js';
1212

1313
/**
@@ -143,4 +143,35 @@ const fieldZoo = defineSeed(FieldZoo, {
143143
],
144144
});
145145

146-
export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, teams, memberships, fieldZoo];
146+
// Invoices owned by different contributors — the controlled-by-parent demo
147+
// (ADR-0055). Under the `showcase_contributor` permission set's owner RLS, a
148+
// contributor (e.g. ada@example.com) sees only the invoices they OWN; because
149+
// `showcase_invoice_line` is `controlled_by_parent`, the lines below follow
150+
// automatically — ada sees INV-1001/1002's lines but never linus's INV-1003.
151+
const invoices = defineSeed(Invoice, {
152+
mode: 'upsert',
153+
externalId: 'name',
154+
records: [
155+
{ name: 'INV-1001', account: 'Northwind', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(10)`, tax_rate: 8 },
156+
{ name: 'INV-1002', account: 'Contoso', owner: 'ada@example.com', status: 'draft', tax_rate: 0 },
157+
{ name: 'INV-1003', account: 'Contoso', owner: 'linus@example.com', status: 'paid', issued_on: cel`daysAgo(30)`, paid_on: cel`daysAgo(2)`, tax_rate: 8 },
158+
{ name: 'INV-1004', account: 'Fabrikam', owner: 'grace@example.com', status: 'draft', tax_rate: 0 },
159+
],
160+
});
161+
162+
// Line items — `product` resolves by SKU (the Product seed's externalId), and
163+
// `invoice` by invoice number. A contributor reaches a line only through its
164+
// master invoice, so these inherit the invoice's owner scoping.
165+
const invoiceLines = defineSeed(InvoiceLine, {
166+
mode: 'upsert',
167+
externalId: 'description',
168+
records: [
169+
{ description: 'INV-1001 \u00b7 Consulting hours', invoice: 'INV-1001', product: 'SERVICE-HR', position: 0, quantity: 10, unit_price: 150, amount: 1500 },
170+
{ description: 'INV-1001 \u00b7 Widget A units', invoice: 'INV-1001', product: 'WIDGET-A', position: 1, quantity: 4, unit_price: 29.99, amount: 119.96 },
171+
{ description: 'INV-1002 \u00b7 Gadget X units', invoice: 'INV-1002', product: 'GADGET-X', position: 0, quantity: 2, unit_price: 99, amount: 198 },
172+
{ description: 'INV-1003 \u00b7 Widget B units', invoice: 'INV-1003', product: 'WIDGET-B', position: 0, quantity: 6, unit_price: 49.99, amount: 299.94 },
173+
{ description: 'INV-1004 \u00b7 Consulting hours', invoice: 'INV-1004', product: 'SERVICE-HR', position: 0, quantity: 3, unit_price: 150, amount: 450 },
174+
],
175+
});
176+
177+
export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, teams, memberships, fieldZoo, invoices, invoiceLines];

examples/app-showcase/src/objects/invoice.object.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ export const Invoice = ObjectSchema.create({
4545
fields: {
4646
name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }),
4747
account: Field.lookup('showcase_account', { label: 'Account', required: true }),
48+
// Owner email — the row-level-security anchor. The `showcase_contributor`
49+
// permission set scopes invoice SELECT to `owner = current_user.email` (email
50+
// is the unique, seedable identifier), so a contributor sees only their own
51+
// invoices; because `showcase_invoice_line` is `controlled_by_parent`, the
52+
// lines follow automatically (ADR-0055). Mirror of `project.owner`.
53+
owner: Field.text({ label: 'Owner', maxLength: 200 }),
4854
status: Field.select({
4955
label: 'Status',
5056
required: true,

examples/app-showcase/src/security/index.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ export const ContributorPermissionSet = {
4040
showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false },
4141
showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4242
showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
43+
// Invoice graph: contributors fully manage invoices + their lines. Read/write
44+
// is scoped by the owner RLS below (invoice) and DERIVED for the lines, which
45+
// are `controlled_by_parent` — no line RLS is authored (ADR-0055).
46+
showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
47+
showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
4348
},
4449
// Field-level security — contributors can read but not edit budget figures.
4550
fields: {
@@ -55,7 +60,22 @@ export const ContributorPermissionSet = {
5560
description: 'Contributors can only select tasks assigned to them.',
5661
object: 'showcase_task',
5762
operation: 'select' as const,
58-
using: "assignee == current_user.name",
63+
using: "assignee = current_user.email",
64+
roles: ['contributor'],
65+
enabled: true,
66+
priority: 10,
67+
},
68+
// Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is
69+
// `controlled_by_parent`, a contributor seeing only their own invoices also
70+
// sees only those invoices' lines — and can by-id read/write a line only when
71+
// they can read/write its master (ADR-0055). No line rule is authored here.
72+
{
73+
name: 'invoice_own_rows',
74+
label: 'Own Invoices Only',
75+
description: "Contributors only see invoices they own; their lines follow via controlled_by_parent.",
76+
object: 'showcase_invoice',
77+
operation: 'select' as const,
78+
using: "owner = current_user.email",
5979
roles: ['contributor'],
6080
enabled: true,
6181
priority: 10,
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// SHOWCASE turnkey-demo proof (ADR-0055). The showcase now SEEDS invoices owned
4+
// by different contributors (ada/linus/grace) + their lines, and the
5+
// `showcase_contributor` permission set scopes invoice SELECT to
6+
// `owner == current_user.name`. Because `showcase_invoice_line` is
7+
// `controlled_by_parent`, each contributor sees ONLY their own invoices' lines.
8+
//
9+
// This boots the REAL showcase app (its seed data loads at ready) and asserts the
10+
// per-owner isolation over that seed, through the real HTTP stack. To exercise the
11+
// owner predicate without a contributor-role user (the showcase seeds no
12+
// non-admin users), it governs sign-ups with an equivalent, non-role-gated mirror
13+
// of the contributor set — same `owner = current_user.email` predicate, same
14+
// derived-line behavior. Sign-ups use the seed owners' emails so
15+
// `current_user.email` resolves and matches.
16+
17+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
18+
import showcaseStack from '@objectstack/example-showcase';
19+
import { bootStack, type VerifyStack } from '@objectstack/verify';
20+
import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security';
21+
import { PermissionSetSchema } from '@objectstack/spec/security';
22+
23+
// Non-role-gated mirror of `showcase_contributor`'s invoice access, so a plain
24+
// sign-up (which holds no role) is governed by it.
25+
const demoSet = PermissionSetSchema.parse({
26+
name: 'showcase_cbp_demo',
27+
label: 'Showcase CBP Demo',
28+
isProfile: true,
29+
objects: {
30+
showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
31+
showcase_product: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
32+
showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
33+
showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
34+
},
35+
rowLevelSecurity: [
36+
{
37+
name: 'invoice_own_rows_demo',
38+
label: 'Own Invoices Only (demo)',
39+
object: 'showcase_invoice',
40+
operation: 'select',
41+
using: 'owner = current_user.email',
42+
enabled: true,
43+
priority: 10,
44+
},
45+
],
46+
});
47+
48+
describe('showcase: seeded invoice/line owner isolation (ADR-0055 turnkey demo)', () => {
49+
let stack: VerifyStack;
50+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
51+
let ql: any;
52+
let adaToken: string;
53+
let linusToken: string;
54+
let adaLineId: string;
55+
let linusLineId: string;
56+
57+
const lineUnder = async (invoiceName: string) => {
58+
const inv = await ql.findOne('showcase_invoice', { where: { name: invoiceName }, context: { isSystem: true } });
59+
expect(inv, `seed invoice ${invoiceName} must exist`).toBeTruthy();
60+
const line = await ql.findOne('showcase_invoice_line', { where: { invoice: inv.id }, context: { isSystem: true } });
61+
expect(line, `seed line under ${invoiceName} must exist`).toBeTruthy();
62+
return { invoiceId: inv.id as string, lineId: line.id as string, owner: inv.owner as string };
63+
};
64+
65+
beforeAll(async () => {
66+
stack = await bootStack(showcaseStack, {
67+
security: new SecurityPlugin({
68+
defaultPermissionSets: [...securityDefaultPermissionSets, demoSet],
69+
fallbackPermissionSet: 'showcase_cbp_demo',
70+
}),
71+
});
72+
await stack.signIn();
73+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
74+
ql = await stack.kernel.getServiceAsync<any>('objectql');
75+
76+
const ada = await lineUnder('INV-1001'); // owner ada@example.com
77+
const linus = await lineUnder('INV-1003'); // owner linus@example.com
78+
adaLineId = ada.lineId;
79+
linusLineId = linus.lineId;
80+
expect(ada.owner).toBe('ada@example.com');
81+
expect(linus.owner).toBe('linus@example.com');
82+
83+
// Sign-ups whose EMAIL matches the seed owner -> `current_user.email` resolves.
84+
adaToken = await stack.signUp('ada@example.com', 'Member-Pass-123');
85+
linusToken = await stack.signUp('linus@example.com', 'Member-Pass-123');
86+
}, 60_000);
87+
88+
afterAll(async () => {
89+
await stack?.stop();
90+
});
91+
92+
it('seed loaded: 4 invoices with expected owners + their lines', async () => {
93+
const invoices = (await ql.find('showcase_invoice', { context: { isSystem: true } })) as Array<{ name: string; owner: string }>;
94+
const byName = Object.fromEntries(invoices.map((i) => [i.name, i.owner]));
95+
expect(byName['INV-1001']).toBe('ada@example.com');
96+
expect(byName['INV-1002']).toBe('ada@example.com');
97+
expect(byName['INV-1003']).toBe('linus@example.com');
98+
expect(byName['INV-1004']).toBe('grace@example.com');
99+
const lines = await ql.find('showcase_invoice_line', { context: { isSystem: true } });
100+
expect(lines.length).toBeGreaterThanOrEqual(5);
101+
});
102+
103+
it('invoice RLS: a contributor lists only the invoices they own', async () => {
104+
const r = await stack.apiAs(adaToken, 'GET', '/data/showcase_invoice');
105+
expect(r.status).toBe(200);
106+
const body = (await r.json()) as any;
107+
const rows: Array<{ name: string }> = body.records ?? body.data ?? body;
108+
const names = rows.map((x) => x.name);
109+
expect(names).toContain('INV-1001');
110+
expect(names).toContain('INV-1002');
111+
expect(names).not.toContain('INV-1003'); // linus's
112+
expect(names).not.toContain('INV-1004'); // grace's
113+
});
114+
115+
it('DERIVED: ada reads her own invoice line but not linus line', async () => {
116+
const own = await stack.apiAs(adaToken, 'GET', `/data/showcase_invoice_line/${adaLineId}`);
117+
expect(own.status, 'ada reads INV-1001 line').toBe(200);
118+
const foreign = await stack.apiAs(adaToken, 'GET', `/data/showcase_invoice_line/${linusLineId}`);
119+
expect(foreign.status, 'ada must not read INV-1003 line').not.toBe(200);
120+
});
121+
122+
it('DERIVED: linus reads his own invoice line but not ada line', async () => {
123+
const own = await stack.apiAs(linusToken, 'GET', `/data/showcase_invoice_line/${linusLineId}`);
124+
expect(own.status, 'linus reads INV-1003 line').toBe(200);
125+
const foreign = await stack.apiAs(linusToken, 'GET', `/data/showcase_invoice_line/${adaLineId}`);
126+
expect(foreign.status, 'linus must not read INV-1001 line').not.toBe(200);
127+
});
128+
});

packages/plugins/plugin-security/src/rls-compiler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ interface RLSUserContext {
2828
* and are merged in under their own keys (see {@link RLSCompiler.compileFilter}).
2929
*/
3030
org_user_ids?: string[];
31+
/**
32+
* The caller's unique, auth-enforced email. RLS expressions reference it as
33+
* `current_user.email` for human-readable, *seedable* owner scoping
34+
* (`owner = current_user.email`). Email is exposed because it is UNIQUE; the
35+
* user's display `name` is deliberately NOT exposed — names collide, and a
36+
* collision on an ownership predicate is an access-control leak.
37+
*/
38+
email?: string;
3139
[key: string]: unknown;
3240
}
3341

@@ -81,6 +89,8 @@ export class RLSCompiler {
8189
organization_id: executionContext?.tenantId,
8290
roles: executionContext?.roles,
8391
org_user_ids: (executionContext as any)?.org_user_ids,
92+
// Unique identifier — safe for ownership predicates (see RLSUserContext).
93+
email: (executionContext as any)?.email,
8494
};
8595

8696
// §7.3.1 dynamic membership: the runtime pre-resolves arbitrary

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,6 +974,29 @@ describe('RLSCompiler', () => {
974974
expect(filter).toEqual({ owner_id: 'user-42' });
975975
});
976976

977+
it('resolves current_user.email to the literal (unique owner anchor)', () => {
978+
// Email is the seedable, unique owner anchor — a data column like
979+
// `owner` can be authored against it and compiles to a concrete literal,
980+
// which is what lets controlled_by_parent resolve master ids under a
981+
// system find (the value is baked in, not a placeholder).
982+
const compiler = new RLSCompiler();
983+
const policy: any = { object: 'showcase_invoice', operation: 'select', using: 'owner = current_user.email' };
984+
const ctx: any = { userId: 'u1', email: 'ada@example.com', roles: [] };
985+
const filter = compiler.compileFilter([policy], ctx);
986+
expect(filter).toEqual({ owner: 'ada@example.com' });
987+
});
988+
989+
it('does NOT resolve current_user.name (collision-prone) → fail-closed', () => {
990+
// Display names are intentionally NOT exposed to RLS: they collide, and a
991+
// collision on an ownership predicate is an access leak. An unresolved
992+
// variable drops the (only) policy → deny sentinel.
993+
const compiler = new RLSCompiler();
994+
const policy: any = { object: 'showcase_invoice', operation: 'select', using: 'owner = current_user.name' };
995+
const ctx: any = { userId: 'u1', email: 'ada@example.com', roles: [] };
996+
const filter = compiler.compileFilter([policy], ctx);
997+
expect(filter).toEqual(RLS_DENY_FILTER);
998+
});
999+
9771000
it('should compile literal equality expression', () => {
9781001
const compiler = new RLSCompiler();
9791002
const policy: any = { object: 'doc', operation: 'select', using: "status = 'published'" };

packages/rest/src/rest-server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,7 @@ export class RestServer {
918918
// key authenticates. Anonymous (neither) → undefined → 401.
919919
let userId: string;
920920
let tenantId: string | undefined;
921+
let email: string | undefined;
921922
const keyPrincipal = await resolveApiKeyPrincipal(identityQl, headers).catch(() => undefined);
922923
if (keyPrincipal) {
923924
userId = keyPrincipal.userId;
@@ -928,6 +929,16 @@ export class RestServer {
928929
if (!session?.user?.id) return undefined;
929930
userId = session.user.id;
930931
tenantId = session.session?.activeOrganizationId ?? undefined;
932+
if (session.user?.email) email = String(session.user.email);
933+
}
934+
// Resolve the caller's UNIQUE email for `current_user.email` RLS owner
935+
// policies (e.g. `owner = current_user.email`). Session-supplied when
936+
// present, else a bounded sys_user lookup (the API-key path). Email is
937+
// the unique owner anchor — display `name` is never exposed to RLS
938+
// (names collide, and a collision on an ownership predicate leaks access).
939+
if (!email && identityQl && typeof identityQl.find === 'function') {
940+
const urows = await identityQl.find('sys_user', { where: { id: userId }, limit: 1, context: { isSystem: true } } as any).catch(() => []);
941+
if ((urows as any)?.[0]?.email) email = String((urows as any)[0].email);
931942
}
932943
// Look up the link tables to surface roles + permission set names.
933944
// Skipping this lookup would silently ignore admin/role grants —
@@ -1052,6 +1063,7 @@ export class RestServer {
10521063
return {
10531064
userId,
10541065
tenantId,
1066+
email,
10551067
roles,
10561068
permissions,
10571069
systemPermissions,

packages/runtime/src/security/resolve-execution-context.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,8 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
192192
userId = sessionData?.user?.id ?? sessionData?.session?.userId;
193193
tenantId = tenantId ?? sessionData?.session?.activeOrganizationId;
194194
ctx.accessToken = sessionData?.session?.token ?? ctx.accessToken;
195+
// Session carries the user's email for free → use it for RLS owner-by-email.
196+
if (sessionData?.user?.email) ctx.email = String(sessionData.user.email);
195197
} catch {
196198
// no auth configured — return anonymous context
197199
}
@@ -209,6 +211,15 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise<Exe
209211
const ql = await opts.getQl();
210212
if (!ql) return ctx;
211213

214+
// Resolve the caller's unique email for `current_user.email` RLS policies when
215+
// the session path didn't supply it (e.g. API-key auth). One bounded lookup;
216+
// email is the auth-unique owner anchor (display name is never used — it
217+
// collides, and a collision on an ownership predicate leaks access).
218+
if (!ctx.email) {
219+
const userRows = await tryFind(ql, 'sys_user', { id: userId }, 1);
220+
if (userRows[0]?.email) ctx.email = String(userRows[0].email);
221+
}
222+
212223
const memberWhere: any = tenantId
213224
? { user_id: userId, organization_id: tenantId }
214225
: { user_id: userId };

packages/spec/src/kernel/execution-context.zod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ import { lazySchema } from '../shared/lazy-schema';
2121
export const ExecutionContextSchema = lazySchema(() => z.object({
2222
/** Current user ID (resolved from session) */
2323
userId: z.string().optional(),
24+
25+
/**
26+
* Current user's unique email (resolved from session, falling back to a
27+
* `sys_user` lookup). Exposed to RLS as `current_user.email` for seedable,
28+
* human-readable owner scoping. Unique by auth invariant — unlike display
29+
* `name`, which is intentionally not surfaced to RLS.
30+
*/
31+
email: z.string().optional(),
2432

2533
/** Current organization/tenant ID (resolved from session.activeOrganizationId) */
2634
tenantId: z.string().optional(),

0 commit comments

Comments
 (0)