Skip to content

Commit 2afb612

Browse files
os-zhuangclaude
andauthored
feat(security): resolve current_user.email in RLS owner policies + owner-scoped showcase invoices (#2054)
* 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 * docs(security): correct the resolvable current_user.* RLS variables The objectql security reference listed `current_user.role`/`current_user.department` "plus any custom user field" as available RLS variables — but the compiler resolves only unique identifiers + pre-resolved membership sets. Corrected to the actual set (`id`, `email`, `organization_id` for equality; `org_user_ids`/`roles`/rlsMembership for IN) and documents that display `name`/arbitrary fields are intentionally not resolvable (collision = access leak). Reflects the new `current_user.email` support. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx * fix(app-showcase): complete required list capabilities on two pages #2052 made `userActions.rowHeight`/`addRecordForm` required on list capabilities and updated most pages, but `active-projects.page.ts` and `task-visualizations.pages.ts` were missed — failing `tsc --noEmit` (TypeScript Type Check) on every PR. Adds the two booleans (`false`, matching the curated-page convention in task-triage/workbench), and `filter: false` on the visualization variants that omitted it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent cd63f70 commit 2afb612

13 files changed

Lines changed: 291 additions & 12 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`.

content/docs/protocol/objectql/security.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ Return filtered result
129129
130130
## 2. Row-Level Security
131131
132-
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a SQL-like `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables include `current_user.id`, `current_user.organization_id`, `current_user.role`, `current_user.department`, plus any custom user field.
132+
Row-level filtering is expressed as **RLS policies** (`RowLevelSecurityPolicySchema` in `packages/spec/src/security/rls.zod.ts`). Policies carry a SQL-like `using` clause (for SELECT/UPDATE/DELETE) and/or a `check` clause (for INSERT/UPDATE). Multiple policies for one object are combined with OR (most-permissive wins). Available context variables are the **unique identifiers and membership sets** the runtime pre-resolves: equality predicates may use `current_user.id`, `current_user.email` (the unique, seedable owner anchor), or `current_user.organization_id`; set-membership predicates may use `id IN (current_user.org_user_ids)`, `IN (current_user.roles)`, or any §7.3.1 set staged in `ExecutionContext.rlsMembership`. Display `name` and arbitrary user fields are **intentionally not** resolvable — only unique identifiers, so an ownership predicate can never leak access through a name collision.
133133
134134
Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata.
135135

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/pages/active-projects.page.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const ActiveProjectsPage: Page = {
3131
element: 'dropdown',
3232
fields: [{ field: 'health', showCount: true }, { field: 'status' }],
3333
},
34-
userActions: { sort: true, search: true, filter: true },
34+
userActions: { sort: true, search: true, filter: true, rowHeight: false, addRecordForm: false },
3535
// Add-record entry point: a toolbar button that opens the default form.
3636
addRecord: { enabled: true, position: 'top', mode: 'form', formView: 'default' },
3737
showRecordCount: true,

examples/app-showcase/src/pages/task-visualizations.pages.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export const TaskBoardPage: Page = {
3333
source: 'showcase_task',
3434
columns: [...cols, 'estimate_hours'],
3535
appearance: { showDescription: true, allowedVisualizations: ['kanban'] },
36-
userActions: { sort: true, search: true },
36+
userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false },
3737
showRecordCount: true,
3838
},
3939
};
@@ -46,7 +46,7 @@ export const TaskCalendarPage: Page = {
4646
source: 'showcase_task',
4747
columns: cols,
4848
appearance: { showDescription: true, allowedVisualizations: ['calendar'] },
49-
userActions: { sort: false, search: true },
49+
userActions: { sort: false, search: true, filter: false, rowHeight: false, addRecordForm: false },
5050
showRecordCount: true,
5151
},
5252
};
@@ -59,7 +59,7 @@ export const TaskGalleryPage: Page = {
5959
source: 'showcase_task',
6060
columns: [...cols, 'cover'],
6161
appearance: { showDescription: true, allowedVisualizations: ['gallery'] },
62-
userActions: { sort: true, search: true },
62+
userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false },
6363
showRecordCount: true,
6464
},
6565
};
@@ -72,7 +72,7 @@ export const TaskSchedulePage: Page = {
7272
source: 'showcase_task',
7373
columns: [...cols, 'start_date', 'end_date', 'progress'],
7474
appearance: { showDescription: true, allowedVisualizations: ['gantt'] },
75-
userActions: { sort: true, search: true },
75+
userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false },
7676
showRecordCount: true,
7777
},
7878
};
@@ -85,7 +85,7 @@ export const TaskTimelinePage: Page = {
8585
source: 'showcase_task',
8686
columns: [...cols, 'created_at'],
8787
appearance: { showDescription: true, allowedVisualizations: ['timeline'] },
88-
userActions: { sort: true, search: true },
88+
userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false },
8989
showRecordCount: true,
9090
},
9191
};
@@ -98,7 +98,7 @@ export const TaskMapPage: Page = {
9898
source: 'showcase_task',
9999
columns: [...cols, 'location'],
100100
appearance: { showDescription: true, allowedVisualizations: ['map'] },
101-
userActions: { sort: false, search: true },
101+
userActions: { sort: false, search: true, filter: false, rowHeight: false, addRecordForm: false },
102102
showRecordCount: true,
103103
},
104104
};
@@ -116,7 +116,7 @@ export const TaskAllViewsPage: Page = {
116116
showDescription: true,
117117
allowedVisualizations: ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map'],
118118
},
119-
userActions: { sort: true, search: true },
119+
userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false },
120120
showRecordCount: true,
121121
},
122122
};

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

0 commit comments

Comments
 (0)