Skip to content

Commit b2552d1

Browse files
hotlongCopilot
andcommitted
feat(security): add multiTenant switch + cache field-name lookups
Single-tenant deployments shouldn't pay the cost of the multi-tenant security pipeline. New `SecurityPlugin({ multiTenant: false })` option (default `true`) disables: 1. organization_id auto-injection on insert (skips a metadata lookup per insert; owner_id injection still runs). 2. Wildcard `tenant_isolation` RLS policy collection — any policy whose USING clause references current_user.organization_id is stripped from the per-request policy set, so the field-existence safety net never has to drop them individually. Owner-based RLS, per-object CRUD checks, and Field-Level Security are unaffected by the flag. Also added a positive-result cache for getObjectFieldNames so repeated inserts/finds against the same object don't re-walk the metadata service / ObjectQL registry on every call. Negative results are not cached because schemas may simply not be registered yet at boot. Wired through both runtime entry points: - packages/cli/src/commands/serve.ts - packages/plugins/plugin-dev/src/dev-plugin.ts both reading OS_MULTI_TENANT (default true). Set OS_MULTI_TENANT=false on the dev / serve command line to switch to single-tenant mode. 4 new tests in security-plugin.test.ts cover both modes (insert auto- inject on/off, find tenant-policy applied/stripped). All 37 plugin tests + 200 runtime tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7fa1380 commit b2552d1

6 files changed

Lines changed: 243 additions & 21 deletions

File tree

CHANGELOG.md

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

1010
### Added
11+
- **`multiTenant` switch on `SecurityPlugin`** (`@objectstack/plugin-security`) — `new SecurityPlugin({ multiTenant: false })` disables the two pieces of the security pipeline that exist solely to support multi-organization deployments: the `organization_id` auto-injection on insert and the wildcard `tenant_isolation` RLS policy (`organization_id = current_user.organization_id`) shipped with the default `member_default` / `viewer_readonly` permission sets. In single-tenant mode every insert skips a metadata lookup, every find skips the field-existence safety net + RLS compile/AND-merge for the wildcard tenant policy, and the per-object schema lookup is now cached (positive results only — a `null` may simply mean the schema isn't registered yet at boot, so we let the next call retry). Owner-based RLS, per-object CRUD checks, and Field-Level Security are unaffected. Both the CLI dev server (`packages/cli/src/commands/serve.ts`) and the dev plugin (`packages/plugins/plugin-dev`) read `OS_MULTI_TENANT` (default `true`); set `OS_MULTI_TENANT=false` to switch a deployment to single-tenant. Four new tests in `security-plugin.test.ts` exercise both modes.
12+
1113
- **Auto-injection of tenancy fields on insert** (`@objectstack/plugin-security`) — When an authenticated, non-system user inserts a record, SecurityPlugin now auto-populates `organization_id` (from `ctx.tenantId`) and `owner_id` (from `ctx.userId`) **only when the field exists on the target object** (looked up via `getObjectFieldNames(metadata, object, ql)`) **and the payload has not already specified it**. This closes the gap that previously caused logged-in users to create rows with `organization_id = NULL`, which the default `tenant_isolation` RLS policy (`organization_id = current_user.organization_id`) would then hide on subsequent reads. System contexts (`ctx.isSystem === true`) are skipped — seeds and platform-admin operations remain explicit. Caller-wins semantics: explicit `organization_id` / `owner_id` in the payload are never overwritten, so cross-org admin grants and cross-tenant link tables (e.g. `sys_user_permission_set` with `organization_id = NULL`) still work.
1214
- **Seed loader runs as system context** (`@objectstack/runtime`) — `SeedLoaderService.writeRecord` and the `app-plugin.ts` basic-insert fallback now pass `{ context: { isSystem: true } }` on every `engine.insert` / `engine.update` / `engine.find` call. This (a) bypasses RBAC checks so seeds can target system tables (`sys_*`) without granting wildcard permissions to a notional seed user, and (b) **disables auto-injection of tenancy fields**, ensuring seed records land exactly as authored — either with an explicit `organization_id` (org-scoped seeds) or with `organization_id = NULL` (intentionally cross-tenant / global metadata such as default permission sets). Combined with the auto-inject change above, this is the missing other half of "新增记录的时候也没有自动加上": user inserts get auto-tagged, seed inserts don't.
1315

packages/cli/src/commands/serve.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -569,7 +569,12 @@ export default class Serve extends Command {
569569
try {
570570
const securityPkg = '@objectstack/plugin-security';
571571
const { SecurityPlugin } = await import(/* webpackIgnore: true */ securityPkg);
572-
await kernel.use(new SecurityPlugin());
572+
// `OS_MULTI_TENANT=false` disables wildcard tenant_isolation
573+
// RLS policies and the `organization_id` auto-injection on
574+
// insert. Keep multi-tenant on by default — most ObjectStack
575+
// deployments are multi-org.
576+
const multiTenant = String(process.env.OS_MULTI_TENANT ?? 'true').toLowerCase() !== 'false';
577+
await kernel.use(new SecurityPlugin({ multiTenant }));
573578
trackPlugin('Security');
574579
} catch {
575580
// optional

packages/plugins/plugin-dev/src/dev-plugin.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -554,9 +554,10 @@ export class DevPlugin implements Plugin {
554554
if (enabled('security')) {
555555
try {
556556
const { SecurityPlugin } = await import('@objectstack/plugin-security') as any;
557-
const securityPlugin = new SecurityPlugin();
557+
const multiTenant = String(process.env.OS_MULTI_TENANT ?? 'true').toLowerCase() !== 'false';
558+
const securityPlugin = new SecurityPlugin({ multiTenant });
558559
this.childPlugins.push(securityPlugin);
559-
ctx.logger.info(' ✔ Security plugin enabled (RBAC, RLS, field masking)');
560+
ctx.logger.info(` ✔ Security plugin enabled (RBAC, RLS, field masking; multiTenant=${multiTenant})`);
560561
} catch {
561562
ctx.logger.debug(' ℹ @objectstack/plugin-security not installed — skipping security');
562563
}

packages/plugins/plugin-security/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,32 @@ kernel.use(new SecurityPlugin());
3333
await kernel.bootstrap();
3434
```
3535

36+
### Multi-tenant vs single-tenant
37+
38+
`SecurityPlugin` defaults to **multi-tenant** mode. In this mode it:
39+
40+
- Auto-injects `organization_id = ctx.tenantId` on insert when the target object declares an `organization_id` field.
41+
- Honours the wildcard `tenant_isolation` RLS policy
42+
(`organization_id = current_user.organization_id`) shipped with the
43+
default `member_default` / `viewer_readonly` permission sets.
44+
45+
For single-tenant deployments, switch it off:
46+
47+
```typescript
48+
kernel.use(new SecurityPlugin({ multiTenant: false }));
49+
```
50+
51+
This skips the per-insert metadata lookup that drives `organization_id`
52+
auto-injection (the `owner_id` injection still runs) and strips wildcard
53+
`current_user.organization_id` policies from the per-request policy
54+
set so the field-existence safety net never has to drop them
55+
individually. Field-Level Security, owner-based RLS, and per-object
56+
CRUD checks operate identically regardless of this flag.
57+
58+
In CLI / dev-server mode the same switch is exposed via the
59+
`OS_MULTI_TENANT` environment variable (default `true`); set
60+
`OS_MULTI_TENANT=false` before `objectstack serve` / `pnpm dev` to disable.
61+
3662
## Key Exports
3763

3864
| Export | Kind | Description |

packages/plugins/plugin-security/src/security-plugin.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,112 @@ describe('SecurityPlugin', () => {
9090
const plugin = new SecurityPlugin();
9191
await expect(plugin.destroy()).resolves.toBeUndefined();
9292
});
93+
94+
// -------------------------------------------------------------------------
95+
// multiTenant switch — single-tenant mode strips tenant policies and skips
96+
// organization_id auto-injection.
97+
// -------------------------------------------------------------------------
98+
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[] }) => {
99+
const fields: Record<string, any> = {};
100+
for (const f of overrides.objectFields ?? ['id', 'organization_id', 'owner_id', 'name']) {
101+
fields[f] = { name: f };
102+
}
103+
let middleware: any;
104+
const ql = {
105+
registerMiddleware: (mw: any) => {
106+
// Capture only the FIRST middleware (the security CRUD one);
107+
// ignore the secondary bootstrap-replay middleware registered
108+
// later in `start()`.
109+
if (!middleware) middleware = mw;
110+
},
111+
getSchema: () => ({ name: 'task', fields }),
112+
};
113+
const metadata = {
114+
get: async () => ({ name: 'task', fields }),
115+
list: async () => overrides.permissionSets,
116+
};
117+
const services: Record<string, any> = {
118+
manifest: { register: vi.fn() },
119+
objectql: ql,
120+
metadata,
121+
};
122+
const ctx: any = {
123+
logger: { info: vi.fn(), warn: vi.fn() },
124+
registerService: vi.fn(),
125+
getService: (name: string) => services[name],
126+
};
127+
return {
128+
ctx,
129+
run: async (opCtx: any) => {
130+
await middleware(opCtx, async () => {});
131+
return opCtx;
132+
},
133+
};
134+
};
135+
136+
const tenantPolicySet: PermissionSet = {
137+
name: 'member_default',
138+
label: 'Member',
139+
isProfile: true,
140+
objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
141+
rowLevelSecurity: [
142+
{ name: 'tenant_isolation', object: '*', operation: 'all', using: 'organization_id = current_user.organization_id' },
143+
],
144+
} as any;
145+
146+
it('multiTenant: true — auto-injects organization_id on insert', async () => {
147+
const plugin = new SecurityPlugin({ multiTenant: true, fallbackPermissionSet: 'member_default' });
148+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
149+
await plugin.init(harness.ctx);
150+
await plugin.start(harness.ctx);
151+
const opCtx: any = {
152+
object: 'task', operation: 'insert', data: { name: 'A' },
153+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
154+
};
155+
await harness.run(opCtx);
156+
expect(opCtx.data.organization_id).toBe('org-1');
157+
expect(opCtx.data.owner_id).toBe('u1');
158+
});
159+
160+
it('multiTenant: false — does NOT auto-inject organization_id, still injects owner_id', async () => {
161+
const plugin = new SecurityPlugin({ multiTenant: false, fallbackPermissionSet: 'member_default' });
162+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
163+
await plugin.init(harness.ctx);
164+
await plugin.start(harness.ctx);
165+
const opCtx: any = {
166+
object: 'task', operation: 'insert', data: { name: 'A' },
167+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
168+
};
169+
await harness.run(opCtx);
170+
expect(opCtx.data.organization_id).toBeUndefined();
171+
expect(opCtx.data.owner_id).toBe('u1');
172+
});
173+
174+
it('multiTenant: false — strips tenant_isolation RLS so find applies no tenant where', async () => {
175+
const plugin = new SecurityPlugin({ multiTenant: false, fallbackPermissionSet: 'member_default' });
176+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
177+
await plugin.init(harness.ctx);
178+
await plugin.start(harness.ctx);
179+
const opCtx: any = {
180+
object: 'task', operation: 'find', ast: { where: undefined },
181+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
182+
};
183+
await harness.run(opCtx);
184+
expect(opCtx.ast.where).toBeUndefined();
185+
});
186+
187+
it('multiTenant: true — applies tenant_isolation RLS to find', async () => {
188+
const plugin = new SecurityPlugin({ multiTenant: true, fallbackPermissionSet: 'member_default' });
189+
const harness = makeMiddlewareCtx({ permissionSets: [tenantPolicySet] });
190+
await plugin.init(harness.ctx);
191+
await plugin.start(harness.ctx);
192+
const opCtx: any = {
193+
object: 'task', operation: 'find', ast: { where: undefined },
194+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
195+
};
196+
await harness.run(opCtx);
197+
expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' });
198+
});
93199
});
94200

95201
// ---------------------------------------------------------------------------

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

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ export interface SecurityPluginOptions {
3030
* @default 'member_default'
3131
*/
3232
fallbackPermissionSet?: string | null;
33+
/**
34+
* Whether this deployment is multi-tenant.
35+
*
36+
* When `true` (default), SecurityPlugin:
37+
* - Auto-injects `organization_id = ctx.tenantId` on insert when
38+
* the target object declares an `organization_id` field.
39+
* - Honours the wildcard `tenant_isolation` RLS policy
40+
* (`organization_id = current_user.organization_id`) shipped with
41+
* the default `member_default` / `viewer_readonly` permission
42+
* sets.
43+
*
44+
* When `false`, SecurityPlugin:
45+
* - Skips the `organization_id` auto-injection block (saves a
46+
* metadata lookup per insert; `owner_id` injection still runs).
47+
* - Strips any RLS policy whose USING expression references
48+
* `current_user.organization_id` from the per-request policy
49+
* set, so single-tenant deployments don't pay the
50+
* field-existence safety-net cost on every find.
51+
*
52+
* Field-Level Security, owner-based RLS, and per-object permission
53+
* checks (allowRead/allowCreate/…) all operate identically regardless
54+
* of this flag. Set this to `false` for single-tenant or
55+
* single-organization deployments where `organization_id` carries no
56+
* meaning.
57+
*
58+
* @default true
59+
*/
60+
multiTenant?: boolean;
3361
}
3462

3563
/**
@@ -56,6 +84,15 @@ export class SecurityPlugin implements Plugin {
5684
private fieldMasker = new FieldMasker();
5785
private readonly bootstrapPermissionSets: PermissionSet[];
5886
private readonly fallbackPermissionSet: string | null;
87+
private readonly multiTenant: boolean;
88+
/**
89+
* Per-object field-name cache. Populated lazily from the metadata
90+
* service / ObjectQL registry on first access per object. Schemas are
91+
* effectively immutable for the lifetime of the kernel today (hot
92+
* reload tears the kernel down), so we don't bother with
93+
* invalidation — a kernel restart drops the cache.
94+
*/
95+
private readonly fieldNamesCache = new Map<string, Set<string> | null>();
5996

6097
constructor(options: SecurityPluginOptions = {}) {
6198
this.bootstrapPermissionSets =
@@ -64,6 +101,7 @@ export class SecurityPlugin implements Plugin {
64101
options.fallbackPermissionSet === undefined
65102
? 'member_default'
66103
: options.fallbackPermissionSet;
104+
this.multiTenant = options.multiTenant !== false;
67105
}
68106

69107
async init(ctx: PluginContext): Promise<void> {
@@ -173,7 +211,7 @@ export class SecurityPlugin implements Plugin {
173211
}
174212
}
175213

176-
// 3.5. Auto-inject tenancy fields on insert.
214+
// 3.5. Auto-inject tenancy/ownership fields on insert.
177215
//
178216
// When an authenticated user inserts a record, the canonical
179217
// tenant column (`organization_id`) and ownership column
@@ -189,28 +227,36 @@ export class SecurityPlugin implements Plugin {
189227
// untouched)
190228
// - aren't already set in the payload (caller wins)
191229
// - have a corresponding value on the execution context.
230+
//
231+
// The `organization_id` half is gated on `multiTenant`; in
232+
// single-tenant deployments it's pure overhead.
192233
if (
193234
opCtx.operation === 'insert' &&
194235
opCtx.data &&
195236
typeof opCtx.data === 'object' &&
196237
!Array.isArray(opCtx.data)
197238
) {
198-
const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
199-
if (fields) {
200-
const data = opCtx.data as Record<string, unknown>;
201-
if (
202-
opCtx.context?.tenantId &&
203-
fields.has('organization_id') &&
204-
(data.organization_id == null || data.organization_id === '')
205-
) {
206-
data.organization_id = opCtx.context.tenantId;
207-
}
208-
if (
209-
opCtx.context?.userId &&
210-
fields.has('owner_id') &&
211-
(data.owner_id == null || data.owner_id === '')
212-
) {
213-
data.owner_id = opCtx.context.userId;
239+
const needsTenant =
240+
this.multiTenant && !!opCtx.context?.tenantId;
241+
const needsOwner = !!opCtx.context?.userId;
242+
if (needsTenant || needsOwner) {
243+
const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
244+
if (fields) {
245+
const data = opCtx.data as Record<string, unknown>;
246+
if (
247+
needsTenant &&
248+
fields.has('organization_id') &&
249+
(data.organization_id == null || data.organization_id === '')
250+
) {
251+
data.organization_id = opCtx.context!.tenantId;
252+
}
253+
if (
254+
needsOwner &&
255+
fields.has('owner_id') &&
256+
(data.owner_id == null || data.owner_id === '')
257+
) {
258+
data.owner_id = opCtx.context!.userId;
259+
}
214260
}
215261
}
216262
}
@@ -324,7 +370,25 @@ export class SecurityPlugin implements Plugin {
324370

325371
for (const ps of permissionSets) {
326372
if (ps.rowLevelSecurity) {
327-
allPolicies.push(...ps.rowLevelSecurity);
373+
for (const policy of ps.rowLevelSecurity) {
374+
// In single-tenant mode, strip any policy that filters on
375+
// `current_user.organization_id` — there is no meaningful
376+
// tenant to compare against, so the policy would either drop
377+
// every row (when the field exists on the object) or be
378+
// dropped by the field-existence safety net. Either way it's
379+
// pure overhead. Substring match is sufficient: every
380+
// wildcard tenant policy in the default permission sets uses
381+
// exactly this token, and authors who want a different
382+
// multi-tenant story should turn `multiTenant: false` off.
383+
if (
384+
!this.multiTenant &&
385+
policy.using &&
386+
policy.using.includes('current_user.organization_id')
387+
) {
388+
continue;
389+
}
390+
allPolicies.push(policy);
391+
}
328392
}
329393
}
330394

@@ -339,6 +403,24 @@ export class SecurityPlugin implements Plugin {
339403
metadata: any,
340404
objectName: string,
341405
ql?: any,
406+
): Promise<Set<string> | null> {
407+
if (this.fieldNamesCache.has(objectName)) {
408+
return this.fieldNamesCache.get(objectName) ?? null;
409+
}
410+
const result = await this.loadObjectFieldNames(metadata, objectName, ql);
411+
// Only cache positive resolutions — a `null` may simply mean the
412+
// schema isn't registered yet at boot, and we want subsequent calls
413+
// to retry rather than be permanently denied.
414+
if (result) {
415+
this.fieldNamesCache.set(objectName, result);
416+
}
417+
return result;
418+
}
419+
420+
private async loadObjectFieldNames(
421+
metadata: any,
422+
objectName: string,
423+
ql?: any,
342424
): Promise<Set<string> | null> {
343425
try {
344426
let obj = await metadata?.get?.('object', objectName);

0 commit comments

Comments
 (0)