Skip to content

Commit 47d04d6

Browse files
committed
Fix RLS tenant_id rewrite and remove legacy ObjectQL tenant middleware
1 parent 77683a2 commit 47d04d6

8 files changed

Lines changed: 83 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11-
- **Phase-1 RBAC end-to-end enforcement (multi-tenant isolation)** — Every authenticated REST request now arrives at the SecurityPlugin middleware with a populated `ExecutionContext`, so RLS/FLS/CRUD checks actually fire. Three previously-silent context-drop sites were closed: (1) `@objectstack/objectql` `protocol.{find,get,create,update,delete}Data` now forward `request.context` into the engine call options; (2) `@objectstack/rest` `RestServer` gained `resolveExecCtx()` plus an `authServiceProvider` constructor hook (wired in `RestApiPlugin` from `ctx.getService('auth')`) that resolves the better-auth session for both single-kernel and multi-kernel deployments and threads `context` into all five CRUD handlers; (3) `@objectstack/plugin-hono-server` raw `/data/:object` fallback handlers now resolve the same context inline and map `PermissionDeniedError` → HTTP 403. `@objectstack/runtime` `resolveExecutionContext()` wraps plain header objects as Web `Headers` so better-auth's cookie lookup works. New seed link tables `sys_user_permission_set` / `sys_role_permission_set` (in `@objectstack/platform-objects`) plus default permission sets `admin_full_access` / `member_default` / `viewer_readonly`; `member_default` carries a wildcard `object: '*'` RLS policy (`tenant_id = current_user.tenant_id`) that SecurityPlugin rewrites onto the configured `tenantField` (default `organization_id`) and skips for tables that lack the field. Verified end-to-end on `pnpm dev:crm`: two users in different organizations each create records and only see their own org's rows on subsequent LISTs across multiple object types. **Known follow-up:** anonymous REST traffic still bypasses enforcement (SecurityPlugin short-circuits when `userId` is absent) — default-deny tightening, Sharing Rule evaluator, Studio RLS visual editor, per-user×org permission cache, and audit UI / denied-access logging remain queued.
11+
- **Phase-1 RBAC end-to-end enforcement (multi-tenant isolation)** — Every authenticated REST request now arrives at the SecurityPlugin middleware with a populated `ExecutionContext`, so RLS/FLS/CRUD checks actually fire. Three previously-silent context-drop sites were closed: (1) `@objectstack/objectql` `protocol.{find,get,create,update,delete}Data` now forward `request.context` into the engine call options; (2) `@objectstack/rest` `RestServer` gained `resolveExecCtx()` plus an `authServiceProvider` constructor hook (wired in `RestApiPlugin` from `ctx.getService('auth')`) that resolves the better-auth session for both single-kernel and multi-kernel deployments and threads `context` into all five CRUD handlers; (3) `@objectstack/plugin-hono-server` raw `/data/:object` fallback handlers now resolve the same context inline and map `PermissionDeniedError` → HTTP 403. `@objectstack/runtime` `resolveExecutionContext()` wraps plain header objects as Web `Headers` so better-auth's cookie lookup works. New seed link tables `sys_user_permission_set` / `sys_role_permission_set` (in `@objectstack/platform-objects`) plus default permission sets `admin_full_access` / `member_default` / `viewer_readonly`; `member_default` carries a wildcard `object: '*'` RLS policy (`tenant_id = current_user.tenant_id`) that SecurityPlugin rewrites onto the configured `tenantField` (default `organization_id`) and skips for tables that lack the field. Two further fixes landed alongside the wiring work: (a) `@objectstack/objectql` lost its legacy `registerTenantMiddleware` (a hardcoded `where.tenant_id = ctx.tenantId` injection that pre-dated SecurityPlugin, masked RLS bugs in older snapshots, and silently broke any table without a `tenant_id` column); SecurityPlugin is now the sole authority for tenant isolation. (b) The `tenantField` rewrite in SecurityPlugin (`tenant_id` → `organization_id` on the LHS column reference) was over-eager and was also rewriting the `current_user.tenant_id` placeholder, producing `current_user.organization_id` which `RLSUserContext` doesn't expose — silently dropping every wildcard tenant policy. The regex now anchors on a non-`.` boundary so only the column reference is rewritten. `member_default` and `viewer_readonly` also gained explicit per-object overrides for the two global tables that lack `organization_id`: `sys_organization_self` (`id = current_user.tenant_id`) and `sys_user_self` (`id = current_user.id`). Verified end-to-end on `pnpm dev:crm`: two users in different organizations each create records and only see their own org's rows on subsequent LISTs across `sys_organization`, `sys_member`, `sys_user`, and the new `sys_user_permission_set` / `sys_role_permission_set` link tables. **Known follow-up:** anonymous REST traffic still bypasses enforcement (SecurityPlugin short-circuits when `userId` is absent) — default-deny tightening, Sharing Rule evaluator, Studio RLS visual editor, per-user×org permission cache, and audit UI / denied-access logging remain queued.
1212
- **Hooks auto-register from `defineStack({ hooks })` (`@objectstack/objectql` + `@objectstack/runtime`)** — Hooks are metadata, and the runtime now treats them as such: `AppPlugin.start()`, `MultiProjectPlugin` seeders, and `ObjectQLPlugin.loadMetadataFromService` all funnel `Hook[]` through a single canonical entry point (`bindHooksToEngine` / `engine.bindHooks`), eliminating the previous boilerplate `engine.registerHook(...)` calls in user code. The binder honours every declarative field on `Hook` — `condition` (compiled as a formula), `async` (fire-and-forget on `after*` events), `retryPolicy` (max retries × linear backoff), `timeout` (Promise.race), `onError` (`'abort'` rethrows, `'log'` swallows), and `priority` — through a new `wrapDeclarativeHook` higher-order function so the engine's `triggerHooks` stays minimal. Adds `engine.registerFunction` / `resolveFunction` / `unregisterFunctionsByPackage` plus `engine.unregisterHooksByPackage(packageId)` for clean hot-reload, and a new `functions` field on `defineStack` so string-named handlers can be resolved by the binder. The built-in audit hooks in `ObjectQLPlugin.registerAuditHooks` were migrated to the same declarative form (dogfood). Example cleanup: `examples/app-crm/src/hooks/register-hooks.ts` deleted; the CRM example now just exports `allHooks` and lists them under `defineStack({ hooks })`.
1313
- **Formula expression evaluator (`@objectstack/objectql`)**`packages/objectql/src/formula.ts` ships a hand-written tokenizer + recursive-descent parser + tree-walking evaluator for the formula function library documented in `packages/spec/docs/formula-functions.md`. Supports text (`CONCAT`/`CONCATENATE`/`UPPER`/`LOWER`/`TEXT`/`LEN`), math (`SUM`/`AVERAGE`/`ROUND`/`CEILING`/`FLOOR`), date (`TODAY`/`NOW`/`YEAR`/`MONTH`/`DAY`/`ADDDAYS`), and logical (`IF`/`AND`/`OR`/`NOT`/`ISBLANK`) functions, plus comparison (`= == != <> < > <= >=`) and arithmetic operators with standard precedence. Public API: `compileFormula(expr)` (cached AST + dependency list) and `evaluateFormula(expr, record)`. Implementation is `eval`-free — untrusted formula strings are safe to evaluate. Used by `formula`-typed fields and decision-node conditions in flows.
1414
- **Studio Flow Viewer + Flow Test Runner**`apps/studio/src/components/FlowViewer.tsx` renders a flow's metadata (variables, nodes, edges, error handling) as inspector cards; `FlowTestRunner.tsx` provides an interactive form for the flow's `isInput` variables, executes the flow against the per-project kernel, and surfaces the result + run record. Wired into the Studio metadata browser via `flow-viewer-plugin.tsx` (registered in `apps/studio/src/plugins/built-in/index.ts`), so any `flow` metadata page exposes a "Run" tab. New `FlowRunsPanel.tsx` lists historical executions for the selected flow.

content/docs/concepts/implementation-status.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,7 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
273273
- Client SDK supports bearer token header — but token validation requires the auth plugin
274274
- Auth route (`/auth/*`) only appears in Discovery when the auth plugin is registered
275275
- Fine-grained authorization (RLS, sharing, territory) is internal to the auth plugin
276-
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `tenant_id = current_user.tenant_id` (rewritten to `organization_id`), which guarantees cross-organization isolation for authenticated users. **Anonymous traffic still bypasses enforcement** until a default-deny pass lands.
276+
- **Phase-1 RBAC enforcement is live end-to-end**: REST → ObjectQL → SecurityPlugin middleware now receives a populated `ExecutionContext` (userId, tenantId, roles, permissions). Default `member_default` permission set ships a wildcard RLS rule `tenant_id = current_user.tenant_id` (rewritten to `organization_id` — only the LHS column is rewritten, the `current_user.tenant_id` placeholder is preserved) plus per-object overrides `sys_organization_self` and `sys_user_self` for the global tables that lack an `organization_id` column. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is now the sole authority for tenant isolation. Verified cross-organization isolation on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still bypasses enforcement** until a default-deny pass lands.
277277

278278
---
279279

@@ -390,8 +390,9 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
390390
### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED**
391391
- [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth)
392392
- [x] Authorization Plugin — `@objectstack/plugin-security` enforces CRUD/FLS/RLS in the ObjectQL middleware chain; REST → ObjectQL now propagates `ExecutionContext` end-to-end (Phase-1)
393-
- [x] Row-Level Security — default `member_default` permission set applies a wildcard `tenant_id = current_user.tenant_id` rule, rewritten to `organization_id`
394-
- [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records)
393+
- [x] Row-Level Security — default `member_default` permission set applies a wildcard `tenant_id = current_user.tenant_id` rule (only the LHS column is rewritten to `organization_id`; the `current_user.tenant_id` placeholder is preserved) plus per-object overrides `sys_organization_self` / `sys_user_self`
394+
- [x] Multi-tenancy — verified cross-organization isolation on `pnpm dev:crm` (Alice@OrgAlpha vs. Bob@OrgBeta only see their own records across `sys_organization`, `sys_member`, `sys_user`, and `sys_*_permission_set` link tables)
395+
- [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority
395396
- [ ] Default-deny for anonymous traffic
396397
- [ ] Sharing Rule evaluator
397398
- [ ] Studio RLS visual editor

content/docs/guides/security.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ description: "Complete guide to implementing enterprise-grade security in Object
77

88
Complete guide to implementing enterprise-grade security in ObjectStack with fine-grained permissions and data access controls.
99

10-
> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `tenant_id = current_user.tenant_id` (rewritten onto the configured `tenantField`, default `organization_id`), giving multi-tenant isolation out of the box. **Anonymous traffic still bypasses enforcement** until a default-deny pass lands; Sharing Rules, Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.
10+
> **Implementation status — Phase-1 RBAC is live.** REST → ObjectQL now propagates a populated `ExecutionContext` (userId, tenantId, roles, permissions) into the SecurityPlugin middleware, so CRUD / FLS / RLS checks actually fire on every authenticated request. The default `member_default` permission set ships a wildcard RLS rule `tenant_id = current_user.tenant_id` (rewritten onto the configured `tenantField`, default `organization_id` — only the LHS column is rewritten, the `current_user.tenant_id` placeholder is preserved) plus explicit per-object overrides `sys_organization_self` (`id = current_user.tenant_id`) and `sys_user_self` (`id = current_user.id`) for the two global tables that lack an `organization_id` column. The legacy `objectql.registerTenantMiddleware` (a hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is now the sole authority for tenant isolation. End-to-end verified on `pnpm dev:crm` across `sys_organization`, `sys_member`, `sys_user`, `sys_user_permission_set`, `sys_role_permission_set`. **Anonymous traffic still bypasses enforcement** until a default-deny pass lands; Sharing Rules, Studio RLS visual editor, per-user×org permission cache, and audit UI for denied access are queued. See `CHANGELOG.md` and `concepts/implementation-status.mdx` for the latest matrix.
1111

1212
## Table of Contents
1313

packages/objectql/src/plugin.ts

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,13 @@ export class ObjectQLPlugin implements Plugin {
197197
// Register built-in audit hooks
198198
this.registerAuditHooks(ctx);
199199

200-
// Register tenant isolation middleware
201-
this.registerTenantMiddleware(ctx);
200+
// Tenant isolation is now handled by `@objectstack/plugin-security`
201+
// via the `member_default` permission set's RLS rule (with field-existence
202+
// guards and configurable tenantField rewrite). The legacy hard-coded
203+
// `tenant_id` filter middleware was removed because it (a) collided with
204+
// the SecurityPlugin RLS pipeline and (b) blindly filtered tables that
205+
// don't have a `tenant_id` column (e.g. `sys_organization`), returning
206+
// 0 rows instead of all rows.
202207

203208
ctx.logger.info('ObjectQL engine started', {
204209
driversRegistered: this.ql?.['drivers']?.size || 0,
@@ -316,35 +321,13 @@ export class ObjectQLPlugin implements Plugin {
316321
}
317322

318323
/**
319-
* Register tenant isolation middleware that auto-injects tenant_id filter
320-
* for multi-tenant operations.
324+
* Tenant isolation moved to `@objectstack/plugin-security`'s
325+
* `member_default` permission set RLS (with field-existence guards and
326+
* configurable `tenantField`). The legacy `registerTenantMiddleware`
327+
* method was removed because it (a) collided with SecurityPlugin's RLS
328+
* pipeline and (b) blindly filtered tables that don't have a `tenant_id`
329+
* column (e.g. `sys_organization`), returning 0 rows instead of all rows.
321330
*/
322-
private registerTenantMiddleware(ctx: PluginContext) {
323-
if (!this.ql) return;
324-
325-
this.ql.registerMiddleware(async (opCtx, next) => {
326-
// Only apply to operations with tenantId that are not system-level
327-
if (!opCtx.context?.tenantId || opCtx.context?.isSystem) {
328-
return next();
329-
}
330-
331-
// Read operations: inject tenant_id filter into AST
332-
if (['find', 'findOne', 'count', 'aggregate'].includes(opCtx.operation)) {
333-
if (opCtx.ast) {
334-
const tenantFilter = { tenant_id: opCtx.context.tenantId };
335-
if (opCtx.ast.where) {
336-
opCtx.ast.where = { $and: [opCtx.ast.where, tenantFilter] };
337-
} else {
338-
opCtx.ast.where = tenantFilter;
339-
}
340-
}
341-
}
342-
343-
await next();
344-
});
345-
346-
ctx.logger.debug('Tenant isolation middleware registered');
347-
}
348331

349332
/**
350333
* Synchronize all registered object schemas to the database.

packages/platform-objects/src/platform-objects.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,21 @@ describe('@objectstack/platform-objects', () => {
109109
expect(wildcard.modifyAllRecords).toBe(true);
110110
});
111111

112-
it('member_default ships tenant + owner RLS policies', () => {
112+
it('member_default ships tenant + owner RLS policies plus better-auth system table guards', () => {
113113
const member = defaultPermissionSets.find((p) => p.name === 'member_default')!;
114114
const policyNames = (member.rowLevelSecurity ?? []).map((p) => p.name).sort();
115115
expect(policyNames).toEqual([
116116
'owner_only_deletes',
117117
'owner_only_writes',
118+
'sys_organization_self',
119+
'sys_user_self',
118120
'tenant_isolation',
119121
]);
120122
const tenantPolicy = (member.rowLevelSecurity ?? []).find((p) => p.name === 'tenant_isolation')!;
121123
expect(tenantPolicy.using).toBe('tenant_id = current_user.tenant_id');
124+
const orgSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_organization_self')!;
125+
expect(orgSelf.object).toBe('sys_organization');
126+
expect(orgSelf.using).toBe('id = current_user.tenant_id');
122127
});
123128

124129
it('viewer_readonly denies writes', () => {

packages/platform-objects/src/security/default-permission-sets.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,20 @@ export const defaultPermissionSets: PermissionSet[] = [
7272
operation: 'delete',
7373
using: 'owner_id = current_user.id',
7474
},
75+
// ── better-auth system tables that lack `organization_id` and would
76+
// otherwise be left unprotected by the wildcard rule above. ────
77+
{
78+
name: 'sys_organization_self',
79+
object: 'sys_organization',
80+
operation: 'all',
81+
using: 'id = current_user.tenant_id',
82+
},
83+
{
84+
name: 'sys_user_self',
85+
object: 'sys_user',
86+
operation: 'select',
87+
using: 'id = current_user.id',
88+
},
7589
],
7690
}),
7791
PermissionSetSchema.parse({
@@ -93,6 +107,18 @@ export const defaultPermissionSets: PermissionSet[] = [
93107
operation: 'select',
94108
using: 'tenant_id = current_user.tenant_id',
95109
},
110+
{
111+
name: 'sys_organization_self',
112+
object: 'sys_organization',
113+
operation: 'select',
114+
using: 'id = current_user.tenant_id',
115+
},
116+
{
117+
name: 'sys_user_self',
118+
object: 'sys_user',
119+
operation: 'select',
120+
using: 'id = current_user.id',
121+
},
96122
],
97123
}),
98124
];

packages/plugins/plugin-security/src/permission-evaluator.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,19 @@ export class PermissionEvaluator {
103103
* Async because the underlying metadata service exposes `list()` as a
104104
* Promise — synchronous iteration would silently yield zero results
105105
* (the historical SecurityPlugin behaviour, masking all enforcement).
106+
*
107+
* `bootstrapPermissionSets` is a fallback list of plugin-owned permission
108+
* sets (typically the platform defaults: admin_full_access /
109+
* member_default / viewer_readonly) that are registered via
110+
* `manifest.register({ permissions })` but do not currently propagate
111+
* into the metadata service's `list()` index. Without this fallback,
112+
* SecurityPlugin would never resolve the defaults and all enforcement
113+
* would be silently disabled for authenticated requests.
106114
*/
107115
async resolvePermissionSets(
108116
identifiers: string[],
109-
metadataService: any
117+
metadataService: any,
118+
bootstrapPermissionSets: PermissionSet[] = []
110119
): Promise<PermissionSet[]> {
111120
if (identifiers.length === 0) return [];
112121

@@ -134,6 +143,17 @@ export class PermissionEvaluator {
134143
}
135144
}
136145

146+
// Fallback: any wanted name not yet matched is sourced from the
147+
// bootstrap list (plugin-owned defaults). Avoids silent failure when
148+
// permission sets are registered via `manifest.register` but the
149+
// metadata service hasn't indexed them.
150+
for (const ps of bootstrapPermissionSets) {
151+
if (wanted.has(ps.name) && !seen.has(ps.name)) {
152+
seen.add(ps.name);
153+
result.push(ps);
154+
}
155+
}
156+
137157
return result;
138158
}
139159
}

0 commit comments

Comments
 (0)