Skip to content

Commit e02d3fc

Browse files
committed
Update files
1 parent 72593bb commit e02d3fc

19 files changed

Lines changed: 1631 additions & 11 deletions

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

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,12 @@ describe('SecurityPlugin', () => {
9595
// multiTenant switch — single-tenant mode strips tenant policies and skips
9696
// organization_id auto-injection.
9797
// -------------------------------------------------------------------------
98-
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[] }) => {
98+
const makeMiddlewareCtx = (overrides: { permissionSets: PermissionSet[]; objectFields?: string[]; schemaExtra?: Record<string, any> }) => {
9999
const fields: Record<string, any> = {};
100100
for (const f of overrides.objectFields ?? ['id', 'organization_id', 'owner_id', 'name']) {
101101
fields[f] = { name: f };
102102
}
103+
const baseSchema: any = { name: 'task', fields, ...(overrides.schemaExtra ?? {}) };
103104
let middleware: any;
104105
const ql = {
105106
registerMiddleware: (mw: any) => {
@@ -108,10 +109,10 @@ describe('SecurityPlugin', () => {
108109
// later in `start()`.
109110
if (!middleware) middleware = mw;
110111
},
111-
getSchema: () => ({ name: 'task', fields }),
112+
getSchema: () => baseSchema,
112113
};
113114
const metadata = {
114-
get: async () => ({ name: 'task', fields }),
115+
get: async () => baseSchema,
115116
list: async () => overrides.permissionSets,
116117
};
117118
const services: Record<string, any> = {
@@ -197,6 +198,70 @@ describe('SecurityPlugin', () => {
197198
expect(opCtx.ast.where).toEqual({ organization_id: 'org-1' });
198199
});
199200

201+
// Regression: when a schema explicitly opts out of tenancy
202+
// (`tenancy.enabled === false` — e.g. `sys_package` Marketplace catalog),
203+
// the wildcard `tenant_isolation` policy targeting `organization_id`
204+
// must be treated as "not applicable" and SKIPPED, NOT fail-closed
205+
// with RLS_DENY_FILTER. Otherwise the registry skips injecting the
206+
// tenant column (correct) but the security plugin still produces zero
207+
// rows on every read (wrong) — which silently broke the cloud
208+
// Marketplace UI.
209+
it('tenancy.enabled=false — wildcard organization_id RLS is skipped, not denied', async () => {
210+
const plugin = new SecurityPlugin({ multiTenant: true, fallbackPermissionSet: 'member_default' });
211+
const harness = makeMiddlewareCtx({
212+
permissionSets: [tenantPolicySet],
213+
// Catalog table without organization_id; opts out of tenancy.
214+
objectFields: ['id', 'manifest_id', 'visibility', 'owner_org_id'],
215+
schemaExtra: { tenancy: { enabled: false, strategy: 'shared' } },
216+
});
217+
await plugin.init(harness.ctx);
218+
await plugin.start(harness.ctx);
219+
const opCtx: any = {
220+
object: 'task', operation: 'find', ast: { where: undefined },
221+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
222+
};
223+
await harness.run(opCtx);
224+
// No deny sentinel, no organization_id where clause: the read
225+
// passes through and the catalog row is visible to every tenant.
226+
expect(opCtx.ast.where).toBeUndefined();
227+
});
228+
229+
it('tenancy.enabled=false via systemFields.tenant=false — also skipped', async () => {
230+
const plugin = new SecurityPlugin({ multiTenant: true, fallbackPermissionSet: 'member_default' });
231+
const harness = makeMiddlewareCtx({
232+
permissionSets: [tenantPolicySet],
233+
objectFields: ['id', 'name'],
234+
schemaExtra: { systemFields: { tenant: false } },
235+
});
236+
await plugin.init(harness.ctx);
237+
await plugin.start(harness.ctx);
238+
const opCtx: any = {
239+
object: 'task', operation: 'find', ast: { where: undefined },
240+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
241+
};
242+
await harness.run(opCtx);
243+
expect(opCtx.ast.where).toBeUndefined();
244+
});
245+
246+
it('tenancy enabled (default) — wildcard organization_id RLS still denies when field is missing', async () => {
247+
// Sanity check: dropping the deny sentinel must remain in effect
248+
// for objects that did NOT opt out — otherwise a wildcard policy
249+
// applied to a half-migrated table would silently expose every row.
250+
const plugin = new SecurityPlugin({ multiTenant: true, fallbackPermissionSet: 'member_default' });
251+
const harness = makeMiddlewareCtx({
252+
permissionSets: [tenantPolicySet],
253+
objectFields: ['id', 'name'], // no organization_id, no opt-out
254+
});
255+
await plugin.init(harness.ctx);
256+
await plugin.start(harness.ctx);
257+
const opCtx: any = {
258+
object: 'task', operation: 'find', ast: { where: undefined },
259+
context: { userId: 'u1', tenantId: 'org-1', roles: [], permissions: [] },
260+
};
261+
await harness.run(opCtx);
262+
expect(opCtx.ast.where).toEqual(RLS_DENY_FILTER);
263+
});
264+
200265
// Post-resolution fallback: roles is non-empty (e.g. better-auth
201266
// sys_member.role = 'owner') but no sys_role binding maps that name to
202267
// a permission set, so resolvePermissionSets returns []. Without the

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

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ export class SecurityPlugin implements Plugin {
9696
* invalidation — a kernel restart drops the cache.
9797
*/
9898
private readonly fieldNamesCache = new Map<string, Set<string> | null>();
99+
/**
100+
* Per-object cache of tenancy opt-out. `true` means the schema
101+
* explicitly disabled multi-tenancy (`tenancy.enabled === false` or
102+
* `systemFields.tenant === false`). Wildcard policies that target
103+
* the conventional tenant column (`organization_id`) are treated as
104+
* *not applicable* on these tables instead of triggering the
105+
* field-missing deny sentinel — without this, every read of a
106+
* cross-org catalog (e.g. `sys_package`, the Marketplace) returns
107+
* zero rows.
108+
*/
109+
private readonly tenancyDisabledCache = new Map<string, boolean>();
99110

100111
constructor(options: SecurityPluginOptions = {}) {
101112
this.bootstrapPermissionSets =
@@ -389,13 +400,25 @@ export class SecurityPlugin implements Plugin {
389400
// When the schema lookup itself fails we keep all policies (drivers
390401
// will surface column errors clearly during compilation).
391402
const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
403+
const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
392404
let dropped = 0;
393405
const compilable = objectFields
394406
? allRlsPolicies.filter((p) => {
395407
const targetField = this.extractTargetField(p.using);
396-
const ok = targetField ? objectFields.has(targetField) : true;
397-
if (!ok) dropped++;
398-
return ok;
408+
if (!targetField) return true;
409+
if (objectFields.has(targetField)) return true;
410+
// Schema-level opt-out: when the object explicitly
411+
// disabled tenancy (`tenancy.enabled === false`), the
412+
// wildcard `tenant_isolation` policy targeting
413+
// `organization_id` was never meant to apply. Treat as
414+
// "not applicable" — skip silently without contributing
415+
// to the deny sentinel, mirroring how the registry skips
416+
// injecting the column itself for these tables.
417+
if (tenancyDisabled && targetField === 'organization_id') {
418+
return false;
419+
}
420+
dropped++;
421+
return false;
399422
})
400423
: allRlsPolicies;
401424
let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
@@ -710,6 +733,15 @@ export class SecurityPlugin implements Plugin {
710733
obj = await metadata?.get?.('object', objectName);
711734
}
712735
if (!obj || !obj.fields) return null;
736+
// Populate the tenancy opt-out cache alongside the field set so
737+
// the RLS filter pass can decide whether a wildcard
738+
// `organization_id` policy is genuinely "applicable but
739+
// uncompilable" (deny) versus "not applicable on this object"
740+
// (skip without contributing to the deny sentinel).
741+
const tenancyDisabled =
742+
(obj as any)?.tenancy?.enabled === false ||
743+
(obj as any)?.systemFields?.tenant === false;
744+
this.tenancyDisabledCache.set(objectName, !!tenancyDisabled);
713745
const set = new Set<string>(['id']);
714746
if (Array.isArray(obj.fields)) {
715747
for (const f of obj.fields) {
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# @objectstack/service-settings
2+
3+
Settings service for ObjectStack. Implements ADR-0007: a generic
4+
namespace **manifest** mechanism + a single K/V table (`sys_setting`) +
5+
a resolver that layers `Env > Tenant > User > Default`.
6+
7+
## What it gives you
8+
9+
- `SettingsServicePlugin` — registers the `sys_setting` schema, exposes
10+
a `settings` service in the kernel, and mounts REST routes on the
11+
HTTP server (when one is available).
12+
- `SettingsService` (`kernel.getService('settings')`)
13+
- `get(ns, key, ctx)` / `getNamespace(ns, ctx)` — resolved values with
14+
`{ value, source, locked }`.
15+
- `set(ns, key, value, scope, ctx)` / `setMany(...)` — writes that
16+
persist into `sys_setting`. Throws when the effective value is
17+
locked by env.
18+
- `registerManifest(manifest)` / `listManifests()` / `getManifest(ns)`.
19+
- `runAction(ns, actionId, input, ctx)` — for "test connection",
20+
"rotate", etc. declared in `action_button` specifiers.
21+
- REST routes (default base path `/api/settings`):
22+
- `GET /api/settings` → manifests visible to caller
23+
- `GET /api/settings/:namespace``{ manifest, values }`
24+
- `PUT /api/settings/:namespace` → batch upsert
25+
- `POST /api/settings/:namespace/:actionId` → invoke declared action
26+
27+
## Env override
28+
29+
`process.env[NAMESPACE_KEY]` (uppercased) takes precedence over any
30+
stored value. Such fields are returned with `source: 'env', locked:
31+
true` and writes (service or REST) fail with HTTP 409.
32+
33+
## Encryption
34+
35+
`Specifier.encrypted: true` (implicit for `password`) round-trips the
36+
value through a pluggable `CryptoAdapter`. The default
37+
`NoopCryptoAdapter` is a base64 wrapper — production deployments must
38+
provide a real KMS adapter via plugin options.
39+
40+
## Audit
41+
42+
Every write emits a `sys_audit_log` row (when the audit service is
43+
present). Encrypted values are masked with `'<encrypted>'` + checksum.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{
2+
"name": "@objectstack/service-settings",
3+
"version": "0.1.0",
4+
"license": "Apache-2.0",
5+
"description": "Settings service for ObjectStack — manifest registry + K/V resolver (Env > Tenant > User > Default) + REST routes. See ADR-0007.",
6+
"type": "module",
7+
"main": "dist/index.js",
8+
"types": "dist/index.d.ts",
9+
"exports": {
10+
".": {
11+
"types": "./dist/index.d.ts",
12+
"import": "./dist/index.js",
13+
"require": "./dist/index.cjs"
14+
}
15+
},
16+
"scripts": {
17+
"build": "tsup --config ../../../tsup.config.ts",
18+
"test": "vitest run"
19+
},
20+
"dependencies": {
21+
"@objectstack/core": "workspace:*",
22+
"@objectstack/platform-objects": "workspace:*",
23+
"@objectstack/spec": "workspace:*"
24+
},
25+
"devDependencies": {
26+
"@types/node": "^25.9.1",
27+
"typescript": "^6.0.3",
28+
"vitest": "^4.1.7"
29+
},
30+
"keywords": ["objectstack", "service", "settings", "config"],
31+
"author": "ObjectStack",
32+
"repository": {
33+
"type": "git",
34+
"url": "https://github.com/objectstack-ai/framework.git",
35+
"directory": "packages/services/service-settings"
36+
},
37+
"publishConfig": { "access": "public" },
38+
"files": ["dist", "README.md"],
39+
"engines": { "node": ">=18.0.0" }
40+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Pluggable adapter for at-rest encryption of `Specifier.encrypted: true`
5+
* values. The default {@link NoopCryptoAdapter} provides a transparent
6+
* base64 wrapping suitable for development and tests; production
7+
* deployments MUST inject a real KMS-backed adapter.
8+
*
9+
* encrypt/decrypt are async to leave room for KMS round-trips.
10+
*/
11+
export interface CryptoAdapter {
12+
/** Returns the ciphertext blob to store in `sys_setting.value_enc`. */
13+
encrypt(plaintext: string, ctx: { namespace: string; key: string }): Promise<string>;
14+
/** Returns the plaintext used by the resolver. */
15+
decrypt(ciphertext: string, ctx: { namespace: string; key: string }): Promise<string>;
16+
/**
17+
* Stable, short, non-reversible digest used for audit-log entries so
18+
* operators can correlate value changes without leaking secrets.
19+
*/
20+
digest(plaintext: string): string;
21+
}
22+
23+
/**
24+
* Development / test default. Base64-wraps the plaintext so the column
25+
* isn't a literal mirror but provides no real confidentiality.
26+
*
27+
* Operators are expected to override this via
28+
* `SettingsServicePluginOptions.crypto`.
29+
*/
30+
export class NoopCryptoAdapter implements CryptoAdapter {
31+
async encrypt(plaintext: string): Promise<string> {
32+
return 'b64:' + Buffer.from(plaintext, 'utf8').toString('base64');
33+
}
34+
async decrypt(ciphertext: string): Promise<string> {
35+
if (!ciphertext.startsWith('b64:')) {
36+
// Tolerate legacy plaintext rows during the dev rollout.
37+
return ciphertext;
38+
}
39+
return Buffer.from(ciphertext.slice(4), 'base64').toString('utf8');
40+
}
41+
digest(plaintext: string): string {
42+
// FNV-1a 32-bit — short, stable, non-cryptographic. Audit-only.
43+
let h = 0x811c9dc5;
44+
for (let i = 0; i < plaintext.length; i++) {
45+
h ^= plaintext.charCodeAt(i);
46+
h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
47+
}
48+
return 'fnv32:' + h.toString(16).padStart(8, '0');
49+
}
50+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Public entrypoint for `@objectstack/service-settings`.
5+
* See ADR-0007 and `README.md`.
6+
*/
7+
8+
export { SettingsService } from './settings-service.js';
9+
export {
10+
type CryptoAdapter,
11+
NoopCryptoAdapter,
12+
} from './crypto-adapter.js';
13+
export {
14+
type SettingsActionHandler,
15+
type SettingsAuditSink,
16+
type SettingsContext,
17+
type SettingsEngine,
18+
type SettingsRow,
19+
type SettingsServiceOptions,
20+
envKeyOf,
21+
SettingsLockedError,
22+
UnknownKeyError,
23+
UnknownNamespaceError,
24+
} from './settings-service.types.js';
25+
export {
26+
SettingsServicePlugin,
27+
type SettingsServicePluginOptions,
28+
} from './settings-service-plugin.js';
29+
export {
30+
registerSettingsRoutes,
31+
type SettingsRoutesOptions,
32+
} from './settings-routes.js';
33+
export {
34+
settingsObjects,
35+
settingsPluginManifestHeader,
36+
SETTINGS_PLUGIN_ID,
37+
SETTINGS_PLUGIN_VERSION,
38+
} from './manifest.js';
39+
40+
// Re-export the spec types for convenience so plugin authors only need
41+
// one import.
42+
export type {
43+
SettingsManifest,
44+
ResolvedSettingValue,
45+
SettingsNamespacePayload,
46+
SettingsActionResult,
47+
SpecifierScope,
48+
} from '@objectstack/spec/system';
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { SysSetting } from '@objectstack/platform-objects/system';
4+
5+
export const SETTINGS_PLUGIN_ID = 'com.objectstack.service.settings';
6+
export const SETTINGS_PLUGIN_VERSION = '0.1.0';
7+
8+
/** Objects owned by service-settings. Currently just the K/V store. */
9+
export const settingsObjects: any[] = [SysSetting];
10+
11+
/** Manifest header shared by compile-time config and runtime registration. */
12+
export const settingsPluginManifestHeader = {
13+
id: SETTINGS_PLUGIN_ID,
14+
namespace: 'sys',
15+
version: SETTINGS_PLUGIN_VERSION,
16+
type: 'plugin' as const,
17+
scope: 'project' as const,
18+
name: 'Settings Service',
19+
description:
20+
'Generic settings registry + K/V resolver with Env > Tenant > User > Default precedence. ADR-0007.',
21+
};

0 commit comments

Comments
 (0)