Skip to content

Commit 77683a2

Browse files
committed
Enforce RBAC end-to-end: REST→ObjectQL context propagation
1 parent 0efe9a0 commit 77683a2

23 files changed

Lines changed: 2340 additions & 77 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +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.
1112
- **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 })`.
1213
- **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.
1314
- **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: 13 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`), which guarantees cross-organization isolation for authenticated users. **Anonymous traffic still bypasses enforcement** until a default-deny pass lands.
276277

277278
---
278279

@@ -386,11 +387,16 @@ 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, 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)
395+
- [ ] Default-deny for anonymous traffic
396+
- [ ] Sharing Rule evaluator
397+
- [ ] Studio RLS visual editor
398+
- [ ] Per-user×org permission cache
399+
- [ ] Audit UI / denied-access logging
394400

395401
### Phase 10: AI Integration 📋 **PLANNED**
396402
- [ ] 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`), 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.
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/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,

packages/platform-objects/src/apps/setup.app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ export const SETUP_APP: App = {
5959
{ id: 'nav_api_keys', type: 'object', label: 'API Keys', objectName: 'sys_api_key', icon: 'key' },
6060
{ id: 'nav_roles', type: 'object', label: 'Roles', objectName: 'sys_role', icon: 'shield-check' },
6161
{ id: 'nav_permission_sets', type: 'object', label: 'Permission Sets', objectName: 'sys_permission_set', icon: 'lock' },
62+
{ id: 'nav_user_permission_sets', type: 'object', label: 'User Permission Sets', objectName: 'sys_user_permission_set', icon: 'user-check' },
63+
{ id: 'nav_role_permission_sets', type: 'object', label: 'Role Permission Sets', objectName: 'sys_role_permission_set', icon: 'shield-plus' },
6264
{ id: 'nav_oauth_apps', type: 'object', label: 'OAuth Apps', objectName: 'sys_oauth_application', icon: 'app-window' },
6365
{ id: 'nav_jwks', type: 'object', label: 'Signing Keys', objectName: 'sys_jwks', icon: 'key-round' },
6466
],

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ import {
1515
SysUserPreference,
1616
SysVerification,
1717
} from './identity/index.js';
18-
import { SysPermissionSet, SysRole } from './security/index.js';
18+
import {
19+
SysPermissionSet,
20+
SysRole,
21+
SysUserPermissionSet,
22+
SysRolePermissionSet,
23+
defaultPermissionSets,
24+
} from './security/index.js';
1925
import { SysAuditLog, SysPresence } from './audit/index.js';
2026
import {
2127
SysApp,
@@ -51,6 +57,8 @@ const systemObjects = [
5157
['SysUserPreference', SysUserPreference, 'sys_user_preference'],
5258
['SysRole', SysRole, 'sys_role'],
5359
['SysPermissionSet', SysPermissionSet, 'sys_permission_set'],
60+
['SysUserPermissionSet', SysUserPermissionSet, 'sys_user_permission_set'],
61+
['SysRolePermissionSet', SysRolePermissionSet, 'sys_role_permission_set'],
5462
['SysAuditLog', SysAuditLog, 'sys_audit_log'],
5563
['SysPresence', SysPresence, 'sys_presence'],
5664
['SysProject', SysProject, 'sys_project'],
@@ -82,4 +90,44 @@ describe('@objectstack/platform-objects', () => {
8290
expect((object as any).namespace).toBeUndefined();
8391
expect((object as any).tableName).toBeUndefined();
8492
});
93+
94+
describe('default permission sets', () => {
95+
it('exposes the three canonical platform permission sets', () => {
96+
const names = defaultPermissionSets.map((p) => p.name).sort();
97+
expect(names).toEqual(['admin_full_access', 'member_default', 'viewer_readonly']);
98+
});
99+
100+
it('admin_full_access grants wildcard CRUD with viewAll/modifyAll', () => {
101+
const admin = defaultPermissionSets.find((p) => p.name === 'admin_full_access')!;
102+
const wildcard = admin.objects['*'];
103+
expect(wildcard).toBeDefined();
104+
expect(wildcard.allowRead).toBe(true);
105+
expect(wildcard.allowCreate).toBe(true);
106+
expect(wildcard.allowEdit).toBe(true);
107+
expect(wildcard.allowDelete).toBe(true);
108+
expect(wildcard.viewAllRecords).toBe(true);
109+
expect(wildcard.modifyAllRecords).toBe(true);
110+
});
111+
112+
it('member_default ships tenant + owner RLS policies', () => {
113+
const member = defaultPermissionSets.find((p) => p.name === 'member_default')!;
114+
const policyNames = (member.rowLevelSecurity ?? []).map((p) => p.name).sort();
115+
expect(policyNames).toEqual([
116+
'owner_only_deletes',
117+
'owner_only_writes',
118+
'tenant_isolation',
119+
]);
120+
const tenantPolicy = (member.rowLevelSecurity ?? []).find((p) => p.name === 'tenant_isolation')!;
121+
expect(tenantPolicy.using).toBe('tenant_id = current_user.tenant_id');
122+
});
123+
124+
it('viewer_readonly denies writes', () => {
125+
const viewer = defaultPermissionSets.find((p) => p.name === 'viewer_readonly')!;
126+
const wildcard = viewer.objects['*'];
127+
expect(wildcard.allowRead).toBe(true);
128+
expect(wildcard.allowCreate).toBe(false);
129+
expect(wildcard.allowEdit).toBe(false);
130+
expect(wildcard.allowDelete).toBe(false);
131+
});
132+
});
85133
});
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security';
4+
5+
/**
6+
* Default permission sets seeded by the platform.
7+
*
8+
* These are referenced by name (`admin_full_access`, `member_default`,
9+
* `viewer_readonly`) from `sys_role_permission_set` rows or assigned
10+
* directly to users via `sys_user_permission_set`.
11+
*
12+
* The runtime SecurityPlugin reads these via the metadata service when a
13+
* permission set name appears in the request `ExecutionContext.permissions[]`.
14+
*
15+
* Each entry is run through `PermissionSetSchema.parse(...)` so Zod fills
16+
* in the boolean/`priority`/`enabled` defaults — keeping the literal
17+
* source readable while still satisfying the strict output type.
18+
*
19+
* `objects: { '*': … }` uses the wildcard sentinel honoured by
20+
* `PermissionEvaluator` — admins do not need an explicit row per object.
21+
*
22+
* RLS policies use the canonical `current_user.*` placeholders compiled
23+
* by `RLSCompiler`. The default tenant column is `organization_id` so the
24+
* SecurityPlugin's `tenantField` config (defaults to `organization_id`)
25+
* rewrites `tenant_id = current_user.tenant_id` accordingly at runtime.
26+
*/
27+
export const defaultPermissionSets: PermissionSet[] = [
28+
PermissionSetSchema.parse({
29+
name: 'admin_full_access',
30+
label: 'Administrator — Full Access',
31+
isProfile: true,
32+
objects: {
33+
'*': {
34+
allowRead: true,
35+
allowCreate: true,
36+
allowEdit: true,
37+
allowDelete: true,
38+
viewAllRecords: true,
39+
modifyAllRecords: true,
40+
},
41+
},
42+
systemPermissions: ['manage_users', 'manage_metadata', 'setup.access'],
43+
}),
44+
PermissionSetSchema.parse({
45+
name: 'member_default',
46+
label: 'Member — Standard Access',
47+
isProfile: true,
48+
objects: {
49+
'*': {
50+
allowRead: true,
51+
allowCreate: true,
52+
allowEdit: true,
53+
allowDelete: true,
54+
},
55+
},
56+
rowLevelSecurity: [
57+
{
58+
name: 'tenant_isolation',
59+
object: '*',
60+
operation: 'all',
61+
using: 'tenant_id = current_user.tenant_id',
62+
},
63+
{
64+
name: 'owner_only_writes',
65+
object: '*',
66+
operation: 'update',
67+
using: 'owner_id = current_user.id',
68+
},
69+
{
70+
name: 'owner_only_deletes',
71+
object: '*',
72+
operation: 'delete',
73+
using: 'owner_id = current_user.id',
74+
},
75+
],
76+
}),
77+
PermissionSetSchema.parse({
78+
name: 'viewer_readonly',
79+
label: 'Viewer — Read-Only',
80+
isProfile: true,
81+
objects: {
82+
'*': {
83+
allowRead: true,
84+
allowCreate: false,
85+
allowEdit: false,
86+
allowDelete: false,
87+
},
88+
},
89+
rowLevelSecurity: [
90+
{
91+
name: 'tenant_isolation',
92+
object: '*',
93+
operation: 'select',
94+
using: 'tenant_id = current_user.tenant_id',
95+
},
96+
],
97+
}),
98+
];

0 commit comments

Comments
 (0)