Skip to content

Commit 1a414f1

Browse files
committed
Remove tenantField rewrite indirection; unify RLS to organization_id
1 parent 45b6fa8 commit 1a414f1

12 files changed

Lines changed: 73 additions & 88 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. 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.
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. Three 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 previous abstract-`tenant_id` indirection in `@objectstack/plugin-security` (`SecurityPluginOptions.tenantField` + a regex that rewrote `tenant_id = current_user.tenant_id` onto the configured physical column at compile time) has been **removed**. The placeholder, the physical column, and `RLSUserContext.organization_id` now use the same canonical name end-to-end (`organization_id`), eliminating an entire class of silent-drop bugs caused by greedy rewrites and removing the most confusing piece of the security DX. Schemas with a different physical tenant column should fork the default permission sets — the runtime `ExecutionContext.tenantId` is unchanged. (c) `member_default` and `viewer_readonly` gained explicit per-object overrides for the two global tables that lack `organization_id`: `sys_organization_self` (`id = current_user.organization_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
- **`@objectstack/driver-mongodb` — first-class MongoDB driver (`packages/plugins/driver-mongodb`)** — A new built-in driver that implements the full `IDataDriver` contract on top of the official `mongodb@^6` Node.js driver. Highlights: per-collection `id` strategy (16-char nanoid stored as a top-level string field with a unique index; the internal `_id` is **never** exposed to consumers — every `find`/`findOne`/`findStream` uses `projection: { _id: 0 }`); pluggable filter translator (`mongodb-filter.ts`) that maps every ObjectStack operator (`$eq`/`$ne`/`$gt`/`$gte`/`$lt`/`$lte`/`$in`/`$nin`/`$exists`/`$contains`/`$startsWith`/`$endsWith`/`$null`/`$between`/`$not`/`$and`/`$or`/`$nor`) plus the legacy `[field, op, value]` tuple form into a native MongoDB query; aggregation pipeline builder (`mongodb-aggregation.ts`) supporting `count`/`sum`/`avg`/`min`/`max`/`count_distinct`/`string_agg` with `$group + $project` flattening; declarative schema sync that creates collections + unique/lookup/text/compound indexes (`mongodb-schema.ts`); cursor-based async streaming via `findStream`; `bulkCreate`/`bulkUpdate`/`bulkDelete` powered by `bulkWrite`; multi-document transactions (requires a replica set) via `MongoClient.startSession()` + `session.withTransaction()`. Ships with **75 unit tests** (filter, aggregation, full driver) running against `mongodb-memory-server` (`onlyBuiltDependencies` whitelist updated in the root `package.json` so the postinstall is allowed). Plugin entry exports a default `onEnable` hook so it can be registered as `kernel.use(mongodbPlugin, { url })` or instantiated directly via `new MongoDBDriver({ url })` and wrapped in `DriverPlugin`.
1313
- **`OS_DATABASE_DRIVER` / `OS_DATABASE_URL` recognised by the CLI, with URL-scheme inference** — `packages/cli/src/commands/serve.ts` now selects the storage driver from `OS_DATABASE_URL` automatically: `mongodb://` / `mongodb+srv://` → `MongoDBDriver`, `postgres://` / `postgresql://` → `SqlDriver(client:'pg')`, `mysql://` / `mysql2://` → `SqlDriver(client:'mysql2')`, `libsql://` or `https://*.turso.…` → `TursoDriver`, `file:` / `sqlite:` / `:memory:` / `*.db` / `*.sqlite` → `SqlDriver(client:'better-sqlite3')`. `OS_DATABASE_DRIVER` remains an explicit override (`mongodb`/`mongo`/`postgres`/`pg`/`mysql`/`sqlite`/`turso`/`libsql`). The same selection logic is mirrored in `packages/services/service-cloud/src/{artifact-environment-registry,environment-registry,project-kernel-factory}.ts` so cloud-runtime nodes can also pin a project to MongoDB. `apps/objectos`, `packages/cli`, and `packages/services/service-cloud` now declare `@objectstack/driver-mongodb` (and the CLI now also declares `@objectstack/driver-turso`) as workspace dependencies so the dynamic `await import()` resolves.
1414
- **MongoDB end-to-end test for the CRM example**`examples/app-crm/playwright.config.ts` + `examples/app-crm/e2e/mongodb-driver.spec.ts` boot `pnpm dev:crm` with `OS_DATABASE_URL=mongodb://localhost:27017/objectstack_crm_test` against a local mongod (driver auto-inferred from the URL scheme — no `OS_DATABASE_DRIVER` needed) and verify (1) the Studio UI responds, (2) seed data is queryable through `GET /api/v1/data/account`, and (3) a full create → read → patch → re-read → delete round-trip lands records in MongoDB. New `pnpm --filter @example/app-crm test:e2e` script wires the suite into the workspace.

content/docs/concepts/implementation-status.mdx

Lines changed: 2 additions & 2 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` — 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.
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 `organization_id = current_user.organization_id` plus per-object overrides `sys_organization_self` (`id = current_user.organization_id`) and `sys_user_self` (`id = current_user.id`) for the global tables that lack an `organization_id` column. The earlier `tenantField` indirection (RLS expressions written against an abstract `tenant_id` column then rewritten to the configured physical column at compile time) was removed — the placeholder, the column name, and `RLSUserContext.organization_id` are now the same name end-to-end. The legacy `objectql.registerTenantMiddleware` (hardcoded `where.tenant_id` injection that pre-dated SecurityPlugin) has been removed; SecurityPlugin is 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,7 +390,7 @@ 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 (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`
393+
- [x] Row-Level Security — default `member_default` permission set applies a wildcard `organization_id = current_user.organization_id` rule plus per-object overrides `sys_organization_self` / `sys_user_self`. The earlier `tenantField` rewrite indirection was removed: RLS column, placeholder, and `RLSUserContext.organization_id` use the same canonical name end-to-end
394394
- [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)
395395
- [x] Legacy `objectql.registerTenantMiddleware` removed — SecurityPlugin is now the sole tenant-isolation authority
396396
- [ ] Default-deny for anonymous traffic

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` — 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.
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 `organization_id = current_user.organization_id` plus explicit per-object overrides `sys_organization_self` (`id = current_user.organization_id`) and `sys_user_self` (`id = current_user.id`) for the two global tables that lack an `organization_id` column. RLS expressions, the physical column, and `RLSUserContext.organization_id` all use the same canonical name — there is no `tenantField` rewrite indirection (schemas with a different physical tenant column should fork the defaults). The legacy `objectql.registerTenantMiddleware` has been removed; SecurityPlugin is 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: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,13 @@ export class ObjectQLPlugin implements Plugin {
198198
this.registerAuditHooks(ctx);
199199

200200
// 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.
201+
// via the `member_default` permission set's RLS rule
202+
// (`organization_id = current_user.organization_id`, with
203+
// field-existence guards). The legacy hard-coded `tenant_id` filter
204+
// middleware was removed because it (a) collided with the
205+
// SecurityPlugin RLS pipeline and (b) blindly filtered tables that
206+
// don't have a `tenant_id` column (e.g. `sys_organization`),
207+
// returning 0 rows instead of all rows.
207208

208209
ctx.logger.info('ObjectQL engine started', {
209210
driversRegistered: this.ql?.['drivers']?.size || 0,
@@ -322,11 +323,13 @@ export class ObjectQLPlugin implements Plugin {
322323

323324
/**
324325
* 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.
326+
* `member_default` permission set RLS
327+
* (`organization_id = current_user.organization_id`, with
328+
* field-existence guards). The legacy `registerTenantMiddleware`
329+
* method was removed because it (a) collided with SecurityPlugin's
330+
* RLS pipeline and (b) blindly filtered tables that don't have a
331+
* `tenant_id` column (e.g. `sys_organization`), returning 0 rows
332+
* instead of all rows.
330333
*/
331334

332335
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,10 +120,10 @@ describe('@objectstack/platform-objects', () => {
120120
'tenant_isolation',
121121
]);
122122
const tenantPolicy = (member.rowLevelSecurity ?? []).find((p) => p.name === 'tenant_isolation')!;
123-
expect(tenantPolicy.using).toBe('tenant_id = current_user.tenant_id');
123+
expect(tenantPolicy.using).toBe('organization_id = current_user.organization_id');
124124
const orgSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_organization_self')!;
125125
expect(orgSelf.object).toBe('sys_organization');
126-
expect(orgSelf.using).toBe('id = current_user.tenant_id');
126+
expect(orgSelf.using).toBe('id = current_user.organization_id');
127127
});
128128

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

0 commit comments

Comments
 (0)