From ded15c363717f43d4fbec99cdc71e0463e7ecc0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 04:38:25 +0000 Subject: [PATCH 1/3] feat(security): resolve current_user.email in RLS owner policies + showcase owner-scoped invoices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .changeset/rls-current-user-email.md | 30 ++++ examples/app-showcase/src/data/index.ts | 35 ++++- .../src/objects/invoice.object.ts | 6 + examples/app-showcase/src/security/index.ts | 22 ++- ...ase-invoice-seed-isolation.dogfood.test.ts | 128 ++++++++++++++++++ .../plugin-security/src/rls-compiler.ts | 10 ++ .../src/security-plugin.test.ts | 23 ++++ packages/rest/src/rest-server.ts | 12 ++ .../src/security/resolve-execution-context.ts | 11 ++ .../spec/src/kernel/execution-context.zod.ts | 8 ++ 10 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 .changeset/rls-current-user-email.md create mode 100644 packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts diff --git a/.changeset/rls-current-user-email.md b/.changeset/rls-current-user-email.md new file mode 100644 index 0000000000..17735100a4 --- /dev/null +++ b/.changeset/rls-current-user-email.md @@ -0,0 +1,30 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-security": minor +"@objectstack/runtime": patch +"@objectstack/rest": patch +"@objectstack/example-showcase": patch +--- + +feat(security): resolve `current_user.email` in RLS owner policies + +RLS `using` predicates can now reference **`current_user.email`** — a unique, +human-readable, *seedable* owner anchor (`owner = current_user.email`). Previously +the RLS compiler resolved only `current_user.id` / `organization_id` / `roles` / +`org_user_ids`, so any owner-by-name/email predicate silently compiled to the +deny sentinel (fail-closed → the user saw nothing). Email is sourced for free +from the auth session (with a bounded `sys_user` fallback for the API-key path) +and threaded onto the `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. + +This makes owner-scoped row-level security work with seed data (no per-user ids +needed) and, combined with `controlled_by_parent` (ADR-0055), lets a master's +owner scoping flow to its detail records. The example-showcase demonstrates it: +`showcase_invoice` carries an `owner` email + an owner RLS policy, its lines are +controlled-by-parent, and invoices/lines are seeded per owner. It also fixes the +showcase's previously inert owner predicates (they used `==` and `current_user.name`, +neither of which the compiler accepts) to `= current_user.email`. diff --git a/examples/app-showcase/src/data/index.ts b/examples/app-showcase/src/data/index.ts index 1366e93d4d..6c98435766 100644 --- a/examples/app-showcase/src/data/index.ts +++ b/examples/app-showcase/src/data/index.ts @@ -7,7 +7,7 @@ import { Project } from '../objects/project.object.js'; import { Task } from '../objects/task.object.js'; import { Category } from '../objects/category.object.js'; import { Team, ProjectMembership } from '../objects/team.object.js'; -import { Product } from '../objects/invoice.object.js'; +import { Product, Invoice, InvoiceLine } from '../objects/invoice.object.js'; import { FieldZoo } from '../objects/field-zoo.object.js'; /** @@ -143,4 +143,35 @@ const fieldZoo = defineSeed(FieldZoo, { ], }); -export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, teams, memberships, fieldZoo]; +// Invoices owned by different contributors — the controlled-by-parent demo +// (ADR-0055). Under the `showcase_contributor` permission set's owner RLS, a +// contributor (e.g. ada@example.com) sees only the invoices they OWN; because +// `showcase_invoice_line` is `controlled_by_parent`, the lines below follow +// automatically — ada sees INV-1001/1002's lines but never linus's INV-1003. +const invoices = defineSeed(Invoice, { + mode: 'upsert', + externalId: 'name', + records: [ + { name: 'INV-1001', account: 'Northwind', owner: 'ada@example.com', status: 'sent', issued_on: cel`daysAgo(10)`, tax_rate: 8 }, + { name: 'INV-1002', account: 'Contoso', owner: 'ada@example.com', status: 'draft', tax_rate: 0 }, + { name: 'INV-1003', account: 'Contoso', owner: 'linus@example.com', status: 'paid', issued_on: cel`daysAgo(30)`, paid_on: cel`daysAgo(2)`, tax_rate: 8 }, + { name: 'INV-1004', account: 'Fabrikam', owner: 'grace@example.com', status: 'draft', tax_rate: 0 }, + ], +}); + +// Line items — `product` resolves by SKU (the Product seed's externalId), and +// `invoice` by invoice number. A contributor reaches a line only through its +// master invoice, so these inherit the invoice's owner scoping. +const invoiceLines = defineSeed(InvoiceLine, { + mode: 'upsert', + externalId: 'description', + records: [ + { description: 'INV-1001 \u00b7 Consulting hours', invoice: 'INV-1001', product: 'SERVICE-HR', position: 0, quantity: 10, unit_price: 150, amount: 1500 }, + { description: 'INV-1001 \u00b7 Widget A units', invoice: 'INV-1001', product: 'WIDGET-A', position: 1, quantity: 4, unit_price: 29.99, amount: 119.96 }, + { description: 'INV-1002 \u00b7 Gadget X units', invoice: 'INV-1002', product: 'GADGET-X', position: 0, quantity: 2, unit_price: 99, amount: 198 }, + { description: 'INV-1003 \u00b7 Widget B units', invoice: 'INV-1003', product: 'WIDGET-B', position: 0, quantity: 6, unit_price: 49.99, amount: 299.94 }, + { description: 'INV-1004 \u00b7 Consulting hours', invoice: 'INV-1004', product: 'SERVICE-HR', position: 0, quantity: 3, unit_price: 150, amount: 450 }, + ], +}); + +export const ShowcaseSeedData = [accounts, products, projects, tasks, categories, teams, memberships, fieldZoo, invoices, invoiceLines]; diff --git a/examples/app-showcase/src/objects/invoice.object.ts b/examples/app-showcase/src/objects/invoice.object.ts index 84712c8fc1..6770b80f8a 100644 --- a/examples/app-showcase/src/objects/invoice.object.ts +++ b/examples/app-showcase/src/objects/invoice.object.ts @@ -45,6 +45,12 @@ export const Invoice = ObjectSchema.create({ fields: { name: Field.text({ label: 'Invoice Number', required: true, searchable: true, maxLength: 60 }), account: Field.lookup('showcase_account', { label: 'Account', required: true }), + // Owner email — the row-level-security anchor. The `showcase_contributor` + // permission set scopes invoice SELECT to `owner = current_user.email` (email + // is the unique, seedable identifier), so a contributor sees only their own + // invoices; because `showcase_invoice_line` is `controlled_by_parent`, the + // lines follow automatically (ADR-0055). Mirror of `project.owner`. + owner: Field.text({ label: 'Owner', maxLength: 200 }), status: Field.select({ label: 'Status', required: true, diff --git a/examples/app-showcase/src/security/index.ts b/examples/app-showcase/src/security/index.ts index 29519021f0..0ce2403f0b 100644 --- a/examples/app-showcase/src/security/index.ts +++ b/examples/app-showcase/src/security/index.ts @@ -40,6 +40,11 @@ export const ContributorPermissionSet = { showcase_project: { allowRead: true, allowCreate: false, allowEdit: true, allowDelete: false }, showcase_task: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + // Invoice graph: contributors fully manage invoices + their lines. Read/write + // is scoped by the owner RLS below (invoice) and DERIVED for the lines, which + // are `controlled_by_parent` — no line RLS is authored (ADR-0055). + showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, }, // Field-level security — contributors can read but not edit budget figures. fields: { @@ -55,7 +60,22 @@ export const ContributorPermissionSet = { description: 'Contributors can only select tasks assigned to them.', object: 'showcase_task', operation: 'select' as const, - using: "assignee == current_user.name", + using: "assignee = current_user.email", + roles: ['contributor'], + enabled: true, + priority: 10, + }, + // Owner RLS on the MASTER invoice. Because `showcase_invoice_line` is + // `controlled_by_parent`, a contributor seeing only their own invoices also + // sees only those invoices' lines — and can by-id read/write a line only when + // they can read/write its master (ADR-0055). No line rule is authored here. + { + name: 'invoice_own_rows', + label: 'Own Invoices Only', + description: "Contributors only see invoices they own; their lines follow via controlled_by_parent.", + object: 'showcase_invoice', + operation: 'select' as const, + using: "owner = current_user.email", roles: ['contributor'], enabled: true, priority: 10, diff --git a/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts b/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts new file mode 100644 index 0000000000..d381e53c50 --- /dev/null +++ b/packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// SHOWCASE turnkey-demo proof (ADR-0055). The showcase now SEEDS invoices owned +// by different contributors (ada/linus/grace) + their lines, and the +// `showcase_contributor` permission set scopes invoice SELECT to +// `owner == current_user.name`. Because `showcase_invoice_line` is +// `controlled_by_parent`, each contributor sees ONLY their own invoices' lines. +// +// This boots the REAL showcase app (its seed data loads at ready) and asserts the +// per-owner isolation over that seed, through the real HTTP stack. To exercise the +// owner predicate without a contributor-role user (the showcase seeds no +// non-admin users), it governs sign-ups with an equivalent, non-role-gated mirror +// of the contributor set — same `owner = current_user.email` predicate, same +// derived-line behavior. Sign-ups use the seed owners' emails so +// `current_user.email` resolves and matches. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema } from '@objectstack/spec/security'; + +// Non-role-gated mirror of `showcase_contributor`'s invoice access, so a plain +// sign-up (which holds no role) is governed by it. +const demoSet = PermissionSetSchema.parse({ + name: 'showcase_cbp_demo', + label: 'Showcase CBP Demo', + isProfile: true, + objects: { + showcase_account: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + showcase_product: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false }, + showcase_invoice: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + showcase_invoice_line: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false }, + }, + rowLevelSecurity: [ + { + name: 'invoice_own_rows_demo', + label: 'Own Invoices Only (demo)', + object: 'showcase_invoice', + operation: 'select', + using: 'owner = current_user.email', + enabled: true, + priority: 10, + }, + ], +}); + +describe('showcase: seeded invoice/line owner isolation (ADR-0055 turnkey demo)', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + let adaToken: string; + let linusToken: string; + let adaLineId: string; + let linusLineId: string; + + const lineUnder = async (invoiceName: string) => { + const inv = await ql.findOne('showcase_invoice', { where: { name: invoiceName }, context: { isSystem: true } }); + expect(inv, `seed invoice ${invoiceName} must exist`).toBeTruthy(); + const line = await ql.findOne('showcase_invoice_line', { where: { invoice: inv.id }, context: { isSystem: true } }); + expect(line, `seed line under ${invoiceName} must exist`).toBeTruthy(); + return { invoiceId: inv.id as string, lineId: line.id as string, owner: inv.owner as string }; + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { + security: new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, demoSet], + fallbackPermissionSet: 'showcase_cbp_demo', + }), + }); + await stack.signIn(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ql = await stack.kernel.getServiceAsync('objectql'); + + const ada = await lineUnder('INV-1001'); // owner ada@example.com + const linus = await lineUnder('INV-1003'); // owner linus@example.com + adaLineId = ada.lineId; + linusLineId = linus.lineId; + expect(ada.owner).toBe('ada@example.com'); + expect(linus.owner).toBe('linus@example.com'); + + // Sign-ups whose EMAIL matches the seed owner -> `current_user.email` resolves. + adaToken = await stack.signUp('ada@example.com', 'Member-Pass-123'); + linusToken = await stack.signUp('linus@example.com', 'Member-Pass-123'); + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('seed loaded: 4 invoices with expected owners + their lines', async () => { + const invoices = (await ql.find('showcase_invoice', { context: { isSystem: true } })) as Array<{ name: string; owner: string }>; + const byName = Object.fromEntries(invoices.map((i) => [i.name, i.owner])); + expect(byName['INV-1001']).toBe('ada@example.com'); + expect(byName['INV-1002']).toBe('ada@example.com'); + expect(byName['INV-1003']).toBe('linus@example.com'); + expect(byName['INV-1004']).toBe('grace@example.com'); + const lines = await ql.find('showcase_invoice_line', { context: { isSystem: true } }); + expect(lines.length).toBeGreaterThanOrEqual(5); + }); + + it('invoice RLS: a contributor lists only the invoices they own', async () => { + const r = await stack.apiAs(adaToken, 'GET', '/data/showcase_invoice'); + expect(r.status).toBe(200); + const body = (await r.json()) as any; + const rows: Array<{ name: string }> = body.records ?? body.data ?? body; + const names = rows.map((x) => x.name); + expect(names).toContain('INV-1001'); + expect(names).toContain('INV-1002'); + expect(names).not.toContain('INV-1003'); // linus's + expect(names).not.toContain('INV-1004'); // grace's + }); + + it('DERIVED: ada reads her own invoice line but not linus line', async () => { + const own = await stack.apiAs(adaToken, 'GET', `/data/showcase_invoice_line/${adaLineId}`); + expect(own.status, 'ada reads INV-1001 line').toBe(200); + const foreign = await stack.apiAs(adaToken, 'GET', `/data/showcase_invoice_line/${linusLineId}`); + expect(foreign.status, 'ada must not read INV-1003 line').not.toBe(200); + }); + + it('DERIVED: linus reads his own invoice line but not ada line', async () => { + const own = await stack.apiAs(linusToken, 'GET', `/data/showcase_invoice_line/${linusLineId}`); + expect(own.status, 'linus reads INV-1003 line').toBe(200); + const foreign = await stack.apiAs(linusToken, 'GET', `/data/showcase_invoice_line/${adaLineId}`); + expect(foreign.status, 'linus must not read INV-1001 line').not.toBe(200); + }); +}); diff --git a/packages/plugins/plugin-security/src/rls-compiler.ts b/packages/plugins/plugin-security/src/rls-compiler.ts index 6a2c840f15..45446a40f3 100644 --- a/packages/plugins/plugin-security/src/rls-compiler.ts +++ b/packages/plugins/plugin-security/src/rls-compiler.ts @@ -28,6 +28,14 @@ interface RLSUserContext { * and are merged in under their own keys (see {@link RLSCompiler.compileFilter}). */ org_user_ids?: string[]; + /** + * The caller's unique, auth-enforced email. RLS expressions reference it as + * `current_user.email` for human-readable, *seedable* owner scoping + * (`owner = current_user.email`). Email is exposed because it is UNIQUE; the + * user's display `name` is deliberately NOT exposed — names collide, and a + * collision on an ownership predicate is an access-control leak. + */ + email?: string; [key: string]: unknown; } @@ -81,6 +89,8 @@ export class RLSCompiler { organization_id: executionContext?.tenantId, roles: executionContext?.roles, org_user_ids: (executionContext as any)?.org_user_ids, + // Unique identifier — safe for ownership predicates (see RLSUserContext). + email: (executionContext as any)?.email, }; // §7.3.1 dynamic membership: the runtime pre-resolves arbitrary diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index acfa994f7a..a2b3ef72d9 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -974,6 +974,29 @@ describe('RLSCompiler', () => { expect(filter).toEqual({ owner_id: 'user-42' }); }); + it('resolves current_user.email to the literal (unique owner anchor)', () => { + // Email is the seedable, unique owner anchor — a data column like + // `owner` can be authored against it and compiles to a concrete literal, + // which is what lets controlled_by_parent resolve master ids under a + // system find (the value is baked in, not a placeholder). + const compiler = new RLSCompiler(); + const policy: any = { object: 'showcase_invoice', operation: 'select', using: 'owner = current_user.email' }; + const ctx: any = { userId: 'u1', email: 'ada@example.com', roles: [] }; + const filter = compiler.compileFilter([policy], ctx); + expect(filter).toEqual({ owner: 'ada@example.com' }); + }); + + it('does NOT resolve current_user.name (collision-prone) → fail-closed', () => { + // Display names are intentionally NOT exposed to RLS: they collide, and a + // collision on an ownership predicate is an access leak. An unresolved + // variable drops the (only) policy → deny sentinel. + const compiler = new RLSCompiler(); + const policy: any = { object: 'showcase_invoice', operation: 'select', using: 'owner = current_user.name' }; + const ctx: any = { userId: 'u1', email: 'ada@example.com', roles: [] }; + const filter = compiler.compileFilter([policy], ctx); + expect(filter).toEqual(RLS_DENY_FILTER); + }); + it('should compile literal equality expression', () => { const compiler = new RLSCompiler(); const policy: any = { object: 'doc', operation: 'select', using: "status = 'published'" }; diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index a9b419f4c4..baba2cf9a0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -918,6 +918,7 @@ export class RestServer { // key authenticates. Anonymous (neither) → undefined → 401. let userId: string; let tenantId: string | undefined; + let email: string | undefined; const keyPrincipal = await resolveApiKeyPrincipal(identityQl, headers).catch(() => undefined); if (keyPrincipal) { userId = keyPrincipal.userId; @@ -928,6 +929,16 @@ export class RestServer { if (!session?.user?.id) return undefined; userId = session.user.id; tenantId = session.session?.activeOrganizationId ?? undefined; + if (session.user?.email) email = String(session.user.email); + } + // Resolve the caller's UNIQUE email for `current_user.email` RLS owner + // policies (e.g. `owner = current_user.email`). Session-supplied when + // present, else a bounded sys_user lookup (the API-key path). Email is + // the unique owner anchor — display `name` is never exposed to RLS + // (names collide, and a collision on an ownership predicate leaks access). + if (!email && identityQl && typeof identityQl.find === 'function') { + const urows = await identityQl.find('sys_user', { where: { id: userId }, limit: 1, context: { isSystem: true } } as any).catch(() => []); + if ((urows as any)?.[0]?.email) email = String((urows as any)[0].email); } // Look up the link tables to surface roles + permission set names. // Skipping this lookup would silently ignore admin/role grants — @@ -1052,6 +1063,7 @@ export class RestServer { return { userId, tenantId, + email, roles, permissions, systemPermissions, diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index e2d62bd76b..c046a6825b 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -192,6 +192,8 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise z.object({ /** Current user ID (resolved from session) */ userId: z.string().optional(), + + /** + * Current user's unique email (resolved from session, falling back to a + * `sys_user` lookup). Exposed to RLS as `current_user.email` for seedable, + * human-readable owner scoping. Unique by auth invariant — unlike display + * `name`, which is intentionally not surfaced to RLS. + */ + email: z.string().optional(), /** Current organization/tenant ID (resolved from session.activeOrganizationId) */ tenantId: z.string().optional(), From 55f49aa33e13618f10c3e5a8874d4309b75a24a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 04:40:20 +0000 Subject: [PATCH 2/3] docs(security): correct the resolvable current_user.* RLS variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- content/docs/protocol/objectql/security.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index 11a08a710a..c800852e89 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -129,7 +129,7 @@ Return filtered result ## 2. Row-Level Security -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. +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. Policies can be attached to a permission set via its `rowLevelSecurity` array, or registered as standalone metadata. From d8d39eca0215036ea952fa545412155e7de79a6c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 04:45:40 +0000 Subject: [PATCH 3/3] fix(app-showcase): complete required list capabilities on two pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 Claude-Session: https://claude.ai/code/session_01XVdnfUAx85amkerym26vdx --- .../app-showcase/src/pages/active-projects.page.ts | 2 +- .../src/pages/task-visualizations.pages.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/app-showcase/src/pages/active-projects.page.ts b/examples/app-showcase/src/pages/active-projects.page.ts index 2d6e078ae9..a32e9223da 100644 --- a/examples/app-showcase/src/pages/active-projects.page.ts +++ b/examples/app-showcase/src/pages/active-projects.page.ts @@ -31,7 +31,7 @@ export const ActiveProjectsPage: Page = { element: 'dropdown', fields: [{ field: 'health', showCount: true }, { field: 'status' }], }, - userActions: { sort: true, search: true, filter: true }, + userActions: { sort: true, search: true, filter: true, rowHeight: false, addRecordForm: false }, // Add-record entry point: a toolbar button that opens the default form. addRecord: { enabled: true, position: 'top', mode: 'form', formView: 'default' }, showRecordCount: true, diff --git a/examples/app-showcase/src/pages/task-visualizations.pages.ts b/examples/app-showcase/src/pages/task-visualizations.pages.ts index 1a6ab31ce4..fcce5d2cf1 100644 --- a/examples/app-showcase/src/pages/task-visualizations.pages.ts +++ b/examples/app-showcase/src/pages/task-visualizations.pages.ts @@ -33,7 +33,7 @@ export const TaskBoardPage: Page = { source: 'showcase_task', columns: [...cols, 'estimate_hours'], appearance: { showDescription: true, allowedVisualizations: ['kanban'] }, - userActions: { sort: true, search: true }, + userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, }; @@ -46,7 +46,7 @@ export const TaskCalendarPage: Page = { source: 'showcase_task', columns: cols, appearance: { showDescription: true, allowedVisualizations: ['calendar'] }, - userActions: { sort: false, search: true }, + userActions: { sort: false, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, }; @@ -59,7 +59,7 @@ export const TaskGalleryPage: Page = { source: 'showcase_task', columns: [...cols, 'cover'], appearance: { showDescription: true, allowedVisualizations: ['gallery'] }, - userActions: { sort: true, search: true }, + userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, }; @@ -72,7 +72,7 @@ export const TaskSchedulePage: Page = { source: 'showcase_task', columns: [...cols, 'start_date', 'end_date', 'progress'], appearance: { showDescription: true, allowedVisualizations: ['gantt'] }, - userActions: { sort: true, search: true }, + userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, }; @@ -85,7 +85,7 @@ export const TaskTimelinePage: Page = { source: 'showcase_task', columns: [...cols, 'created_at'], appearance: { showDescription: true, allowedVisualizations: ['timeline'] }, - userActions: { sort: true, search: true }, + userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, }; @@ -98,7 +98,7 @@ export const TaskMapPage: Page = { source: 'showcase_task', columns: [...cols, 'location'], appearance: { showDescription: true, allowedVisualizations: ['map'] }, - userActions: { sort: false, search: true }, + userActions: { sort: false, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, }; @@ -116,7 +116,7 @@ export const TaskAllViewsPage: Page = { showDescription: true, allowedVisualizations: ['grid', 'kanban', 'gallery', 'calendar', 'timeline', 'gantt', 'map'], }, - userActions: { sort: true, search: true }, + userActions: { sort: true, search: true, filter: false, rowHeight: false, addRecordForm: false }, showRecordCount: true, }, };