Skip to content

Commit 2029a89

Browse files
committed
2 parents 895fc6e + 45b6fa8 commit 2029a89

23 files changed

Lines changed: 1194 additions & 109 deletions

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-
- **`apps/objectos` honors `OS_DATABASE_URL` / `OS_DATABASE_DRIVER` for the project DB** — `packages/services/service-cloud/src/runtime-stack.ts` (single-project local mode) now selects the project data driver from the same env vars that the CLI's `objectstack serve` uses, with URL-scheme inference (`mongodb://` → mongodb, `postgres://` → pg, `mysql://` → mysql, `libsql://` / `https://*.turso.*` → turso, `file:` / `*.db` / `*.sqlite` / `:memory:` → sqlite). To pin the **control-plane** DB independently from the project DB, a new `OS_CONTROL_DATABASE_URL` env var is introduced and resolved in `cloud-stack.ts`; for backward compatibility the legacy `OS_DATABASE_URL` is still accepted as a fallback for the control DB when neither `OS_CONTROL_DATABASE_URL` nor an explicit `controlDriverUrl` is supplied. Verified end-to-end: `OS_DATABASE_URL=mongodb://localhost:27017/objectstack_objectos_test pnpm dev` boots `apps/objectos`, control DB stays on local sqlite (`apps/objectos/.objectstack/data/control.db`), and project records (CRUD on `/api/v1/data/...`) land in MongoDB.
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
- **`@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: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -263,16 +263,17 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
263263
| **Organization** | Authentication |||| 📋 Plugin |
264264
| **Policy** | Authentication |||| 📋 Plugin |
265265
| **SCIM** | Authentication |||| 📋 Plugin |
266-
| **Permission** | Authorization || || 📋 Plugin |
266+
| **Permission** | Authorization || ✅ Phase-1 || `plugin-security` |
267267
| **Sharing** | Authorization |||| 📋 Plugin |
268-
| **RLS** | Authorization || || 📋 Plugin |
268+
| **RLS** | Authorization || ✅ Phase-1 (tenant + owner) || `plugin-security` |
269269
| **Territory** | Authorization |||| 📋 Plugin |
270270

271271
**Notes:**
272272
- All security protocols (identity + permission) are delivered by a single `auth` plugin — matching `CoreServiceName`
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.
276277

277278
---
278279

@@ -386,11 +387,17 @@ The `auth` service in `CoreServiceName` covers both **authentication** (identity
386387
- [ ] ETL Pipeline Plugin
387388
- [ ] Trigger Registry Plugin
388389

389-
### Phase 9: Security (Plugin) 📋 **PLANNED**
390-
- [ ] Authentication Plugin
391-
- [ ] Authorization Plugin
392-
- [ ] Row-Level Security Plugin
393-
- [ ] Multi-tenancy Plugin
390+
### Phase 9: Security (Plugin) 🟡 **PHASE-1 LANDED**
391+
- [x] Authentication Plugin (`@objectstack/plugin-auth`, better-auth)
392+
- [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`
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
396+
- [ ] Default-deny for anonymous traffic
397+
- [ ] Sharing Rule evaluator
398+
- [ ] Studio RLS visual editor
399+
- [ ] Per-user×org permission cache
400+
- [ ] Audit UI / denied-access logging
394401

395402
### Phase 10: AI Integration 📋 **PLANNED**
396403
- [ ] Agent Framework

content/docs/guides/security.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ 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.
11+
1012
## Table of Contents
1113

1214
1. [Security Architecture](#security-architecture)

content/docs/references/system/translation.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ Translation data for a single object
109109
| **pluralLabel** | `string` | optional | Translated plural label |
110110
| **description** | `string` | optional | Translated object description |
111111
| **fields** | `Record<string, Object>` | optional | Field-level translations |
112+
| **_views** | `Record<string, Object>` | optional | View translations keyed by view name |
113+
| **_actions** | `Record<string, Object>` | optional | Action translations keyed by action name |
112114

113115

114116
---
@@ -192,6 +194,7 @@ Translation data for objects, apps, and UI messages
192194
| **apps** | `Record<string, Object>` | optional | App translations keyed by app name |
193195
| **messages** | `Record<string, string>` | optional | UI message translations keyed by message ID |
194196
| **validationMessages** | `Record<string, string>` | optional | Translatable validation error messages keyed by rule name (e.g., `{"discount_limit": "折扣不能超过40%"}`) |
197+
| **globalActions** | `Record<string, Object>` | optional | Global action translations keyed by action name |
195198
| **dashboards** | `Record<string, Object>` | optional | Dashboard translations keyed by dashboard name |
196199

197200

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/objectql/src/protocol.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -505,8 +505,14 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
505505
}
506506
}
507507

508-
async findData(request: { object: string, query?: any }) {
508+
async findData(request: { object: string, query?: any, context?: any }) {
509509
const options: any = { ...request.query };
510+
// Forward the dispatcher's ExecutionContext so RBAC/RLS middleware
511+
// can apply per-request enforcement. The protocol layer is purely
512+
// a normalizer — it must never strip security context.
513+
if (request.context !== undefined) {
514+
options.context = request.context;
515+
}
510516

511517
// ====================================================================
512518
// Normalize legacy params → QueryAST standard (where/fields/orderBy/offset/expand)
@@ -643,10 +649,13 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
643649
};
644650
}
645651

646-
async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[] }) {
652+
async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[], context?: any }) {
647653
const queryOptions: any = {
648654
where: { id: request.id }
649655
};
656+
if (request.context !== undefined) {
657+
queryOptions.context = request.context;
658+
}
650659

651660
// Support fields for single-record retrieval
652661
if (request.select) {
@@ -677,28 +686,34 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol {
677686
throw new Error(`Record ${request.id} not found in ${request.object}`);
678687
}
679688

680-
async createData(request: { object: string, data: any }) {
681-
const result = await this.engine.insert(request.object, request.data);
689+
async createData(request: { object: string, data: any, context?: any }) {
690+
const result = await this.engine.insert(
691+
request.object,
692+
request.data,
693+
request.context !== undefined ? { context: request.context } as any : undefined,
694+
);
682695
return {
683696
object: request.object,
684697
id: result.id,
685698
record: result
686699
};
687700
}
688701

689-
async updateData(request: { object: string, id: string, data: any }) {
690-
// Adapt: update(obj, id, data) -> update(obj, data, options)
691-
const result = await this.engine.update(request.object, request.data, { where: { id: request.id } });
702+
async updateData(request: { object: string, id: string, data: any, context?: any }) {
703+
const opts: any = { where: { id: request.id } };
704+
if (request.context !== undefined) opts.context = request.context;
705+
const result = await this.engine.update(request.object, request.data, opts);
692706
return {
693707
object: request.object,
694708
id: request.id,
695709
record: result
696710
};
697711
}
698712

699-
async deleteData(request: { object: string, id: string }) {
700-
// Adapt: delete(obj, id) -> delete(obj, options)
701-
await this.engine.delete(request.object, { where: { id: request.id } });
713+
async deleteData(request: { object: string, id: string, context?: any }) {
714+
const opts: any = { where: { id: request.id } };
715+
if (request.context !== undefined) opts.context = request.context;
716+
await this.engine.delete(request.object, opts);
702717
return {
703718
object: request.object,
704719
id: request.id,

0 commit comments

Comments
 (0)