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
5 changes: 5 additions & 0 deletions .changeset/formula-add-days-months.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@objectstack/formula": minor
---

Add `addDays(date, n)` and `addMonths(date, n)` to the CEL standard library — shift an arbitrary date by a (possibly negative) number of days or months. Unlike `daysFromNow`, these operate on a *given* date (the "next service date = last service + cycle" shape). `addMonths` clamps to the target month's last day (`addMonths(date('2026-01-31'), 1)` → Feb 28, never overflowing into March). Both coerce their inputs (Date | ISO string | epoch) and type `n` as `dyn` so a record number field arriving as a `double` doesn't fault `no such overload` (#1928).
10 changes: 10 additions & 0 deletions .changeset/rls-converge-docs-dynamic-membership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-security": minor
---

Converge the RLS contract with the reference compiler, and wire §7.3.1 dynamic membership.

- **spec (docs)**: narrow `rls.zod.ts` to the four expression forms the compiler actually implements — `field = current_user.<prop>`, `field = 'literal'`, `field IN (current_user.<array>)`, and `1 = 1`. Removed the over-promised surface (subqueries, `AND`/`OR`/`NOT`, `LIKE`/`ILIKE`, regex, `ANY`/`ALL`, `NOT IN`, `IS NULL`, `NOW()`/`CURRENT_DATE`) from the operator list, context-variable list, and `@example` policies, and documented the fail-closed behaviour explicitly.
- **spec (schema)**: `ExecutionContext` gains `rlsMembership?: Record<string, string[]>` — a bag of pre-resolved dynamic-membership id arrays (team members, territory accounts, shared records) that the runtime stages so RLS can scope via `field IN (current_user.<key>)` without subquery support. Generalizes the previously hard-coded `org_user_ids`.
- **plugin-security**: `RLSCompiler.compileFilter` merges `rlsMembership` keys into the user context (arrays only, never clobbering the named `id`/`organization_id`/`roles`/`org_user_ids` fields), so §7.3.1 hierarchy- and sharing-based policies compile. `compileExpression` now recognizes `1 = 1` as always-true (empty filter), making `RLS.allowAllPolicy` grant access instead of silently failing closed. Missing/empty membership sets still fail closed.
11 changes: 11 additions & 0 deletions .changeset/sandbox-ctx-api-transaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": minor
"@objectstack/runtime": minor
---

Add a transaction boundary to sandboxed hook/action bodies: `ctx.api.transaction(async () => { … })`. Every `ctx.api` read/write inside the callback runs in one driver transaction — committed when the callback returns, rolled back if it throws (or if the body leaves the transaction open at timeout). Guarded by the new `api.transaction` capability.

- **spec**: new `api.transaction` capability token on `HookBodyCapability`.
- **objectql**: `ScopedContext` gains discrete `beginTransaction()` / `commitTransaction(handle)` / `rollbackTransaction(handle)` primitives. The handle is threaded **explicitly** through a child context (`resolveTx` honors it ahead of the ambient `txStore`), because the sandbox drives the body across many host event-loop turns where AsyncLocalStorage context does not survive. Degrades to non-transactional execution when the driver has no transaction support.
- **runtime**: the QuickJS runner wires `ctx.api.transaction` over three deferred-promise host leaves (begin/commit/rollback), routes in-transaction ops through the tx-scoped context, and rolls back a transaction the body left open before disposing the VM.
25 changes: 25 additions & 0 deletions packages/formula/src/cel-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,30 @@ describe('celEngine', () => {
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-02-14T00:00:00.000Z');
});

it('addDays(date, n) shifts an arbitrary date — the "next service date" shape', () => {
// 下次维保日期 = 上次维保 + 周期天数. Operates on record.date, not now().
const r = celEngine.evaluate(cel('addDays(record.last_service, record.cycle_days)'), {
record: { last_service: '2026-06-18T00:00:00Z', cycle_days: 90 },
});
expect(r.ok).toBe(true);
if (r.ok) expect((r.value as Date).toISOString()).toBe('2026-09-16T00:00:00.000Z');
});

it('addDays accepts a negative offset', () => {
const r = celEngine.evaluate(cel("addDays(date('2026-03-01'), -1)"), {});
expect(r.ok && (r.value as Date).toISOString()).toBe('2026-02-28T00:00:00.000Z');
});

it('addMonths clamps to the target month\'s last day (Jan 31 + 1mo → Feb 28)', () => {
const r = celEngine.evaluate(cel("addMonths(date('2026-01-31'), 1)"), {});
expect(r.ok && (r.value as Date).toISOString()).toBe('2026-02-28T00:00:00.000Z');
});

it('addMonths handles year roll-over and the common 6-month cycle', () => {
const r = celEngine.evaluate(cel("addMonths(date('2026-09-30'), 6)"), {});
expect(r.ok && (r.value as Date).toISOString()).toBe('2027-03-30T00:00:00.000Z');
});

it('today()/daysFromNow()/daysAgo() resolve the calendar day in the reference timezone', () => {
// 2026-01-15T02:00Z is still Jan 14 in America/New_York (UTC-5).
const pinned = new Date('2026-01-15T02:00:00Z');
Expand Down Expand Up @@ -368,6 +392,7 @@ describe('celEngine', () => {
const call: Record<string, string> = {
now: 'now()', today: 'today()', daysFromNow: 'daysFromNow(30)', daysAgo: 'daysAgo(7)',
daysBetween: 'daysBetween(today(), daysFromNow(7))', date: 'date("2026-03-15")',
addDays: 'addDays(today(), 7)', addMonths: 'addMonths(today(), 3)',
datetime: 'datetime("2026-03-15T08:00:00Z")', abs: 'abs(-3.5)', round: 'round(2.6)',
min: 'min(1, 2)', max: 'max(1, 2)', upper: 'upper("hi")', lower: 'lower("HI")',
trim: 'trim(" x ")', contains: 'contains("hello", "ell")', startsWith: 'startsWith("hi", "h")',
Expand Down
32 changes: 32 additions & 0 deletions packages/formula/src/stdlib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ function addDaysUtc(d: Date, n: number): Date {
return out;
}

/**
* Add `n` calendar months to a Date in UTC; returns a new Date. Clamps the day
* to the target month's last day so `addMonths(date('2026-01-31'), 1)` yields
* Feb 28, never an overflow into March — matching how authors expect a
* "next service date = last + N months" rule to behave.
*/
function addMonthsUtc(d: Date, n: number): Date {
const out = new Date(d.getTime());
const day = out.getUTCDate();
out.setUTCDate(1); // avoid roll-over while shifting the month
out.setUTCMonth(out.getUTCMonth() + n);
const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate();
out.setUTCDate(Math.min(day, lastDay));
return out;
}

/**
* Register the ObjectStack standard library into a CEL environment.
*
Expand Down Expand Up @@ -161,6 +177,22 @@ export function registerStdLib(
(a: unknown, b: unknown) =>
BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)),
)
// Shift an arbitrary date by a (possibly negative) number of days/months.
// Unlike `daysFromNow`, these operate on a *given* date — the shape behind
// "next service date = last service + cycle". Args are coerced (Date | ISO
// string | epoch) so a `Field.date` arriving as a string works directly.
// `addMonths` clamps to the target month's last day (Jan 31 +1mo → Feb 28).
// `n` is typed `dyn` (not `int`): a record number field arrives as cel-js
// `double`, so an `int` overload would fault `no such overload` (#1928).
// We coerce defensively with `Number(...)`.
.registerFunction(
'addDays(dyn, dyn): google.protobuf.Timestamp',
(d: unknown, n: unknown) => addDaysUtc(toDate(d), Math.trunc(Number(n))),
)
.registerFunction(
'addMonths(dyn, dyn): google.protobuf.Timestamp',
(d: unknown, n: unknown) => addMonthsUtc(toDate(d), Math.trunc(Number(n))),
)
// Parse an ISO date / date-time string to a Timestamp. `date` and `datetime`
// are aliases — both accept either form (the field's own type decides the
// intent); kept distinct because authors reach for whichever reads clearer.
Expand Down
2 changes: 1 addition & 1 deletion packages/formula/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ export function introspectScope(role: FieldRole, schema?: ExprSchemaHint): {
*/
export const CEL_STDLIB_FUNCTIONS: string[] = [
// Dates (registered stdlib)
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'date', 'datetime',
'now', 'today', 'daysFromNow', 'daysAgo', 'daysBetween', 'addDays', 'addMonths', 'date', 'datetime',
// Numbers (registered stdlib)
'abs', 'round', 'min', 'max',
// Strings (registered stdlib)
Expand Down
58 changes: 58 additions & 0 deletions packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3081,6 +3081,64 @@ export class ScopedContext {
}
}

/**
* Resolve the default driver, if it exposes transaction primitives.
* Shared by {@link transaction} and the discrete begin/commit/rollback trio.
*/
private txDriver(): any | undefined {
const engine = this.engine as any;
const driver = engine.defaultDriver
? engine.drivers?.get(engine.defaultDriver)
: undefined;
return driver?.beginTransaction ? driver : undefined;
}

/**
* Discrete transaction primitives — `begin` / `commit` / `rollback` as three
* separate calls, in contrast to {@link transaction}'s single-callback form.
*
* This trio exists for callers that cannot keep a JS closure on the stack for
* the lifetime of the transaction — chiefly the sandbox runner, where the
* hook/action body's `ctx.api.transaction(fn)` is driven across many host
* event-loop turns via deferred promises. Across those `setImmediate`
* boundaries the engine's ambient `txStore` (AsyncLocalStorage) does NOT
* survive, so the transaction handle is threaded **explicitly**: `begin`
* returns a child ScopedContext carrying `transaction: trx` in its execution
* context, and `resolveTx` honors that explicit handle ahead of the ambient
* store. Every `object(...)` op on the returned context therefore reuses the
* one connection without relying on ALS.
*
* Returns `null` when the driver has no transaction support — the caller then
* runs non-transactionally against `this` (same graceful degrade as
* {@link transaction}).
*/
async beginTransaction(): Promise<{ ctx: ScopedContext; handle: unknown } | null> {
const driver = this.txDriver();
if (!driver) return null;
const trx = await driver.beginTransaction();
const ctx = new ScopedContext(
{ ...this.executionContext, transaction: trx },
this.engine
);
return { ctx, handle: trx };
}

/** Commit a handle obtained from {@link beginTransaction}. */
async commitTransaction(handle: unknown): Promise<void> {
const driver = this.txDriver();
if (!driver) return;
if (driver.commit) await driver.commit(handle);
else if (driver.commitTransaction) await driver.commitTransaction(handle);
}

/** Roll back a handle obtained from {@link beginTransaction}. */
async rollbackTransaction(handle: unknown): Promise<void> {
const driver = this.txDriver();
if (!driver) return;
if (driver.rollback) await driver.rollback(handle);
else if (driver.rollbackTransaction) await driver.rollbackTransaction(handle);
}

get userId() { return this.executionContext.userId; }
get tenantId() { return this.executionContext.tenantId; }
/** Alias for tenantId — matches ObjectQLContext.spaceId convention */
Expand Down
44 changes: 38 additions & 6 deletions packages/plugins/plugin-security/src/rls-compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ interface RLSUserContext {
* current user (incl. self). Pre-resolved by the runtime so RLS can
* scope identity tables like `sys_user` via
* `id IN (current_user.org_user_ids)` without needing subquery
* support in the compiler.
* support in the compiler. This is the one well-known membership set;
* arbitrary §7.3.1 sets arrive via `ExecutionContext.rlsMembership`
* and are merged in under their own keys (see {@link RLSCompiler.compileFilter}).
*/
org_user_ids?: string[];
[key: string]: unknown;
Expand Down Expand Up @@ -81,6 +83,21 @@ export class RLSCompiler {
org_user_ids: (executionContext as any)?.org_user_ids,
};

// §7.3.1 dynamic membership: the runtime pre-resolves arbitrary
// set-membership (team members, territory accounts, shared records)
// into `ExecutionContext.rlsMembership`. Merge each set under its key
// so `field IN (current_user.<key>)` resolves without subquery support.
// Arrays only; a missing/empty set still fails closed downstream.
// We never let a membership key clobber the named fields above.
const membership = (executionContext as any)?.rlsMembership;
if (membership && typeof membership === 'object') {
for (const [key, value] of Object.entries(membership)) {
if (Array.isArray(value) && userCtx[key] === undefined) {
userCtx[key] = value;
}
}
}

const filters: Record<string, unknown>[] = [];

for (const policy of policies) {
Expand All @@ -107,18 +124,33 @@ export class RLSCompiler {

/**
* Compile a single RLS expression into a query filter.
*
* Supports simple expressions like:
* - "field_name = current_user.property"
* - "field_name IN (current_user.array_property)"
* - "field_name = 'literal_value'"
*
* This reference compiler recognizes exactly four forms — anything else
* returns `null` and (via {@link compileFilter}) fails closed:
* - `field = current_user.property` → `{ field: <value> }`
* - `field = 'literal_value'` → `{ field: 'literal_value' }`
* - `field IN (current_user.array)` → `{ field: { $in: [...] } }`
* (the array may be a §7.3.1 pre-resolved membership set)
* - `1 = 1` → `{}` (always-true / no restriction)
*
* There is intentionally no support for subqueries, `LIKE`/`ILIKE`,
* regex, `ANY`/`ALL`, `AND`/`OR`/`NOT`, or `NULL` checks — express those
* needs as a `current_user.*` property the runtime pre-resolves instead.
*/
compileExpression(
expression: string,
userCtx: RLSUserContext
): Record<string, unknown> | null {
if (!expression) return null;

// Always-true literal: "1 = 1" → no restriction (match every row).
// Lets RLS.allowAllPolicy ('1 = 1' for privileged roles) grant access
// instead of silently failing closed. An empty filter AND's onto the
// caller's where clause as a no-op.
if (/^\s*1\s*=\s*1\s*$/.test(expression)) {
return {};
}

// Handle simple equality: "field = current_user.property"
const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/);
if (eqMatch) {
Expand Down
73 changes: 73 additions & 0 deletions packages/plugins/plugin-security/src/security-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PermissionEvaluator } from './permission-evaluator.js';
import { FieldMasker } from './field-masker.js';
import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js';
import type { PermissionSet } from '@objectstack/spec/security';
import { RLS } from '@objectstack/spec/security';

// ---------------------------------------------------------------------------
// SecurityPlugin – basic metadata
Expand Down Expand Up @@ -1096,4 +1097,76 @@ describe('RLSCompiler', () => {
const contactFind = compiler.getApplicablePolicies('contact', 'find', policies);
expect(contactFind).toHaveLength(2); // contact all + * all
});

// §7.3.1 dynamic membership — arbitrary pre-resolved sets in rlsMembership
it('should resolve IN against a §7.3.1 pre-resolved rlsMembership set', () => {
// Manager hierarchy: the runtime resolved the manager's reports into
// ctx.rlsMembership.team_member_ids. The compiler merges that bag into
// the user context so `IN (current_user.team_member_ids)` resolves
// without any subquery support.
const compiler = new RLSCompiler();
const policy: any = {
object: 'task',
operation: 'select',
using: 'assigned_to_id IN (current_user.team_member_ids)',
};
const ctx: any = {
userId: 'mgr-1',
tenantId: 'org-1',
roles: ['manager'],
rlsMembership: { team_member_ids: ['u2', 'u3', 'u4'] },
};
const filter = compiler.compileFilter([policy], ctx);
expect(filter).toEqual({ assigned_to_id: { $in: ['u2', 'u3', 'u4'] } });
});

it('should fail-closed when a §7.3.1 membership set is empty', () => {
// A manager with no reports → empty team_member_ids → the IN policy
// drops out → sole policy → deny sentinel (zero rows). Never fail-open.
const compiler = new RLSCompiler();
const policy: any = {
object: 'task',
operation: 'select',
using: 'assigned_to_id IN (current_user.team_member_ids)',
};
const ctx: any = { userId: 'mgr-1', tenantId: 'org-1', roles: [], rlsMembership: { team_member_ids: [] } };
const filter = compiler.compileFilter([policy], ctx);
expect(filter).toEqual(RLS_DENY_FILTER);
});

it('should not let a rlsMembership key clobber a named context field', () => {
// roles is a first-class field; a hostile/misconfigured membership bag
// must not override it. The named `roles` (['real-role']) wins; the
// policy compiles against it, not the injected ['spoofed'].
const compiler = new RLSCompiler();
const policy: any = { object: 'x', operation: 'select', using: 'role_id IN (current_user.roles)' };
const ctx: any = {
userId: 'u1',
tenantId: 'org-1',
roles: ['real-role'],
rlsMembership: { roles: ['spoofed'] },
};
const filter = compiler.compileFilter([policy], ctx);
expect(filter).toEqual({ role_id: { $in: ['real-role'] } });
});

// Always-true literal — makes RLS.allowAllPolicy grant access instead of
// silently failing closed.
it('should compile "1 = 1" to an unrestricted (empty) filter', () => {
const compiler = new RLSCompiler();
const policy: any = { object: 'account', operation: 'all', using: '1 = 1', roles: ['ceo'] };
const ctx: any = { userId: 'u1', tenantId: 'org-1', roles: ['ceo'] };
const filter = compiler.compileFilter([policy], ctx);
expect(filter).toEqual({}); // matches every row
});

it('should grant allow-all via RLS.allowAllPolicy instead of denying', () => {
// Regression guard: '1 = 1' used to be unsupported → deny sentinel,
// making the helper do the opposite of its name. Now it grants.
const compiler = new RLSCompiler();
const policy = RLS.allowAllPolicy('account', ['ceo', 'cfo']) as any;
const filter = compiler.compileFilter([policy]);
expect(filter).toEqual({});
expect(filter).not.toEqual(RLS_DENY_FILTER);
});
});
Loading
Loading