Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/rls-current-user-email.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 33 additions & 2 deletions examples/app-showcase/src/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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];
6 changes: 6 additions & 0 deletions examples/app-showcase/src/objects/invoice.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/pages/active-projects.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions examples/app-showcase/src/pages/task-visualizations.pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
};
Expand All @@ -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,
},
};
Expand All @@ -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,
},
};
Expand All @@ -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,
},
};
Expand All @@ -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,
},
};
Expand All @@ -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,
},
};
Expand All @@ -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,
},
};
22 changes: 21 additions & 1 deletion examples/app-showcase/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
Expand Down
128 changes: 128 additions & 0 deletions packages/dogfood/test/showcase-invoice-seed-isolation.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<any>('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);
});
});
10 changes: 10 additions & 0 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading