diff --git a/.changeset/24341-delegated-ceiling-no-object-default.md b/.changeset/24341-delegated-ceiling-no-object-default.md new file mode 100644 index 00000000000..b580e14ccfd --- /dev/null +++ b/.changeset/24341-delegated-ceiling-no-object-default.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): a delegated agent's own leg does not collect an object's declared default row scope (#24341) + +Clause-②: no + +An agent acting on behalf of a user (ADR-0090 D10) is judged on two legs that the read door ANDs together: its own sets, which are a capability ceiling and carry no row policy, and its delegator's sets. Since the row-policy collection began applying an object's declared `defaultRowScope` where the held sets carry no row policy, the ceiling's silent leg collected that default too. Where the delegator's own leg is wider than the default, the agent then read fewer rows than its delegator. The platform administrator is the case in point: the administrator's own leg skips row policies on the identity objects, so an agent acting for the administrator would have read only the administrator's own rows there, where it read every row before. + +The collection no longer adds an object's default on a delegated principal's own leg, so a ceiling's silence is not read as the object's default. The delegator's leg collects the default exactly as any principal's does, and a principal that acts for no one keeps the default whenever its sets are silent. + +What moves, against `18.0.0-next.5`: on an object that declares `defaultRowScope`, an agent acting for a user reads the rows its delegator's side admits, and its explain output names no object default on the agent's side. Before this release's reader, that was already so. + +**Unchanged.** + +- A principal that acts for no one, a human or otherwise, collects an object's default when its sets carry no row policy for the object and operation, as before. +- The delegator's side, the superuser bypass, and every authored policy are judged as before. +- No export, option or type moves. diff --git a/.changeset/24341-extend-default-row-scope-refused.md b/.changeset/24341-extend-default-row-scope-refused.md new file mode 100644 index 00000000000..5f79d1fb710 --- /dev/null +++ b/.changeset/24341-extend-default-row-scope-refused.md @@ -0,0 +1,26 @@ +--- +'@objectstack/objectql': minor +--- + +fix(objectql)!: the registry refuses an `extend` contribution that carries `defaultRowScope` (#24341) + +Clause-②: no (narrowing) + +An object's default row scope is declared by the object's owner alone. `SchemaRegistry.registerObject` now refuses an `extend` contribution that carries `defaultRowScope`, whatever its value and whatever the target, with `INVALID_METADATA` (`status: 422`) and the error name `ExtensionDefaultRowScopeRefusedError`, naming the object and the extending package. It is refused before the contribution is recorded, so nothing of it lands, and the owner's declaration stays in force. + +Every value is refused because no narrower line holds. Whether one row predicate admits more rows than another cannot be decided in general, so a "loosening" cannot be told from a "narrowing". And the extension merge never carried the key onto the object, so an accepted one restricted nothing while its author believed it did. + +**BREAKING — what moves for consumers.** This is an accept-set narrowing on the registry's `extend` door, shipped as `minor` under the launch-window convention for accept-set narrowings. + +- FROM: `registerObject(def, packageId, undefined, 'extend')` with `def.defaultRowScope` set registered the contribution and dropped the key. +- TO: it throws `INVALID_METADATA` (`422`, `ExtensionDefaultRowScopeRefusedError`). +- Fix: remove `defaultRowScope` from the extension. To scope the rows the holders of a permission set read on the object, give that set a `rowLevelSecurity` policy for it. + +**Unchanged.** + +- An `extend` contribution that leaves the key out, or sets it to `undefined`, registers and folds as before. The one shipped `extend` of a platform object in this repository (`@objectstack/plugin-auth`'s `sys_sso_provider` extension) registers, measured on a booted composition. +- A manifest's `objectExtensions` entry never reaches this refusal: its schema is strict and refuses the key at authoring, and `ObjectQL.registerApp` copies a closed key list off each entry. +- The owner's own `defaultRowScope` registers and re-registers as before. +- No export moves: the error class is not exported, and its code is the existing `INVALID_METADATA`. + + diff --git a/.changeset/24341-identity-objects-default-row-scope.md b/.changeset/24341-identity-objects-default-row-scope.md new file mode 100644 index 00000000000..6a1bd58028e --- /dev/null +++ b/.changeset/24341-identity-objects-default-row-scope.md @@ -0,0 +1,27 @@ +--- +'@objectstack/platform-objects': minor +'@objectstack/plugin-audit': minor +--- + +feat(platform-objects)!: the platform identity objects declare their default row scope (#24341) + +Clause-②: no (narrowing) + +Twenty-three platform objects now declare `defaultRowScope`, each with the row policy the shipped permission sets of `@objectstack/plugin-security` carry for it, moved verbatim: the seven SCIM projections (`sys_scim_user`, `sys_scim_subject`, `sys_scim_projection_grant`, `sys_scim_identity_tombstone`, `sys_scim_group`, `sys_scim_group_member`, `sys_scim_connection_binding`), `sys_verification` and `sys_jwks`, `sys_organization`, `sys_user`, `sys_session`, `sys_account`, `sys_team_member`, `sys_two_factor`, `sys_user_preference`, `sys_api_key`, `sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token`, `sys_oauth_consent`, `sys_oauth_application` (`@objectstack/platform-objects`) and `sys_audit_log` (`@objectstack/plugin-audit`, which owns the ledger). `sys_user` is the one merge: the key holds one policy, so its two shipped policies are joined into `id == current_user.id || id in current_user.org_user_ids`, a `select` policy. The platform-global objects (`sys_metadata*`, `sys_job*`, `sys_migration*`, `sys_platform_setting`, `sys_presence`, `sys_flow_dispatch`) declare none. + +The security plugin's row-policy collection applies an object's declared default when the permission sets the caller holds carry no row policy for that object and operation. The shipped sets still carry the same policies, so a holder of them reads exactly what it read before. + +**BREAKING — what moves for consumers.** This narrows what a principal reads on these objects when its sets carry no row policy for them, shipped as `minor` under the launch-window convention for accept-set narrowings. + +- FROM: a principal holding no platform baseline (a deployment that sets the security plugin's `fallbackPermissionSet: null`) whose sets grant read on an identity object and carry no row policy for it read every row of that object. +- TO: it reads the object's declared default: its own rows (`user_id == current_user.id`); on `sys_user`, its own row plus the members of the organisation it acts in; on `sys_organization`, its own organisation; no row on `sys_verification`, `sys_jwks`, `sys_scim_group`, `sys_scim_group_member` and `sys_scim_connection_binding`; on `sys_audit_log`, its organisation's entries where organisation scoping is on (the platform's tenant policies, this one included, are stripped where it is off). +- Fix: to give such a principal more rows, give one of its permission sets a `rowLevelSecurity` policy for the object. A policy a held set carries is used as authored, and the default is not added to it. + +**Unchanged.** + +- A deployment with the platform baseline (`member_default`, the default) serves every human exactly what it served before, on every one of the 23 objects and every operation: each human holds the baseline, and the baseline carries a policy for each object and operation. Measured on a booted composition with a member, a viewer, an organisation admin, a user in no organisation and the platform administrator, before and after the declarations. +- An MCP agent acting for a user reads the same rows, and gets the same explain output, as before. Its ceiling set carries no row policy, and the security plugin does not collect an object's declared default on a delegated principal's own side of the intersection (see the `@objectstack/plugin-security` entry), so the agent's rows stay bounded by its delegator's side alone. Measured on a booted composition, before and after the declarations, for the agents of a member, a viewer, an organisation admin and the platform administrator; the platform administrator's agent still reads every row. +- The platform administrator's superuser bypass is decided before any row policy is collected. +- No export, option or stored shape moves. + + diff --git a/packages/objectql/src/registry-extension-default-row-scope-refused.test.ts b/packages/objectql/src/registry-extension-default-row-scope-refused.test.ts new file mode 100644 index 00000000000..234053eb039 --- /dev/null +++ b/packages/objectql/src/registry-extension-default-row-scope-refused.test.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0095 D1, 2026-10-18 note; ruling of record 6250748677 on #24164, letter + * E′ item 4] An `extend` contribution cannot set or loosen an object's declared + * default row scope: `registerObject` refuses one that carries + * `defaultRowScope`, by name, before it opens the contributor list. + * + * The refusal is pinned on its envelope (`code` + `status`) and its `name`, the + * condition `INVALID_METADATA` shares with other refusals in this package — + * never on a bare throw. Its controls: an extension that leaves the key alone + * still registers and still folds, and the owner's own declaration of the key + * is untouched by either. + */ + +import { describe, it, expect } from 'vitest'; +import { EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE, SchemaRegistry } from './registry.js'; + +const OWNER_PKG = 'com.objectstack.platform-objects'; +const EXTENDER_PKG = 'app.extender'; +const OBJECT_NAME = 'sys_user'; + +/** The owner's declaration — `sys_user`'s own default row scope. */ +const OWNER_SCOPE = { + name: 'sys_user_self_or_org_members', + operation: 'select', + using: 'id == current_user.id || id in current_user.org_user_ids', +}; + +function registryWithOwner(): SchemaRegistry { + const r = new SchemaRegistry({ multiTenant: false }); + r.logLevel = 'silent'; + r.registerObject( + { + name: OBJECT_NAME, + label: 'User', + defaultRowScope: OWNER_SCOPE, + fields: { email: { name: 'email', type: 'text', label: 'Email' } }, + } as any, + OWNER_PKG, + ); + return r; +} + +function refusalOf(fn: () => unknown): any { + try { + fn(); + } catch (e) { + return e; + } + return undefined; +} + +describe('registerObject refuses an `extend` contribution that carries `defaultRowScope`', () => { + it.each([ + ['loosens it to every row', { name: 'everyone', operation: 'select', using: 'id != null' }], + ['repeats the owner\'s own scope', OWNER_SCOPE], + ['narrows it to no row', { name: 'nobody', operation: 'all', using: 'id == null' }], + ])('refuses an extension that %s, by name, and the object keeps the owner\'s scope', (_label, scope) => { + const r = registryWithOwner(); + const err = refusalOf(() => + r.registerObject( + { + name: OBJECT_NAME, + defaultRowScope: scope, + fields: { nickname: { name: 'nickname', type: 'text', label: 'Nickname' } }, + } as any, + EXTENDER_PKG, + undefined, + 'extend', + ), + ); + + expect(err).toBeDefined(); + expect({ code: err.code, status: err.status, name: err.name }).toEqual({ + code: EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE, + status: 422, + name: 'ExtensionDefaultRowScopeRefusedError', + }); + expect(EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE).toBe('INVALID_METADATA'); + expect({ objectName: err.objectName, packageId: err.packageId }).toEqual({ + objectName: OBJECT_NAME, + packageId: EXTENDER_PKG, + }); + + // Nothing of the refused contribution landed: no contributor, no field. + expect(r.getObjectContributors(OBJECT_NAME).map((c) => c.packageId)).toEqual([OWNER_PKG]); + const resolved = r.getObject(OBJECT_NAME) as any; + expect(resolved.defaultRowScope).toEqual(OWNER_SCOPE); + expect(resolved.fields.nickname).toBeUndefined(); + }); + + it('refuses it on an object whose owner declares no default too: the fold would never carry it', () => { + const r = new SchemaRegistry({ multiTenant: false }); + r.logLevel = 'silent'; + r.registerObject( + { name: 'app_ticket', label: 'Ticket', fields: { title: { name: 'title', type: 'text', label: 'Title' } } } as any, + 'app.owner', + ); + const err = refusalOf(() => + r.registerObject( + { name: 'app_ticket', defaultRowScope: { name: 'own', operation: 'select', using: 'id != null' } } as any, + EXTENDER_PKG, + undefined, + 'extend', + ), + ); + expect({ code: err?.code, status: err?.status, name: err?.name }).toEqual({ + code: 'INVALID_METADATA', + status: 422, + name: 'ExtensionDefaultRowScopeRefusedError', + }); + expect((r.getObject('app_ticket') as any).defaultRowScope).toBeUndefined(); + }); + + it('control: an extension that leaves `defaultRowScope` alone registers and folds as before', () => { + const r = registryWithOwner(); + r.registerObject( + { + name: OBJECT_NAME, + label: 'Person', + fields: { nickname: { name: 'nickname', type: 'text', label: 'Nickname' } }, + } as any, + EXTENDER_PKG, + undefined, + 'extend', + ); + // An explicitly-undefined key sets nothing, so it is not a contribution of one. + r.registerObject( + { name: OBJECT_NAME, defaultRowScope: undefined, fields: { pronouns: { name: 'pronouns', type: 'text', label: 'Pronouns' } } } as any, + 'app.second-extender', + undefined, + 'extend', + ); + + expect(r.getObjectContributors(OBJECT_NAME).map((c) => c.packageId)).toEqual([ + OWNER_PKG, + EXTENDER_PKG, + 'app.second-extender', + ]); + const resolved = r.getObject(OBJECT_NAME) as any; + expect(resolved.label).toBe('Person'); + expect(Object.keys(resolved.fields)).toEqual(expect.arrayContaining(['email', 'nickname', 'pronouns'])); + expect(resolved.defaultRowScope).toEqual(OWNER_SCOPE); + }); + + it('control: the owner\'s own `defaultRowScope` registers, and re-registers, unrefused', () => { + const r = registryWithOwner(); + expect(() => + r.registerObject( + { name: OBJECT_NAME, label: 'User', defaultRowScope: OWNER_SCOPE, fields: {} } as any, + OWNER_PKG, + ), + ).not.toThrow(); + expect((r.getObject(OBJECT_NAME) as any).defaultRowScope).toEqual(OWNER_SCOPE); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 6991094f504..92fa10f0dfd 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1465,6 +1465,65 @@ class ObjectFieldTypeRefusedError extends Error implements ObjectFieldTypeRefusa } } +/** + * {@link ExtensionDefaultRowScopeRefusedError}'s code — `INVALID_METADATA`, + * this package's spelling for "a programmatically supplied metadata body does + * not satisfy the spec schema" (see {@link OBJECT_FIELD_TYPE_REFUSED_CODE}): + * `ObjectExtensionSchema` is strict and declares no `defaultRowScope`, so the + * parse refuses the same body at authoring. ⛔ Not a new code. The condition is + * named by the error's `name`. + */ +export const EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE = 'INVALID_METADATA' as const; + +/** + * [ADR-0095 D1, 2026-10-18 note; ruling of record 6250748677 on #24164, letter + * E′ item 4] An `extend` contribution carried `defaultRowScope`. An object's + * default row scope is the owning declaration's alone: the platform identity + * objects declare theirs (Layer 1 silence means that scope, not no filter), and + * an extender "cannot set or loosen a platform object's declared default; + * refused at registration with a named reason". + * + * Refused whatever the value and whatever the target, because no other line is + * decidable or honest: whether one row predicate admits more rows than another + * is not decidable for CEL predicates in general, so "loosens" cannot be judged + * pairwise, and the ruling's own verb is "set"; and the fold + * ({@link mergeObjectDefinitions}) never carries the key onto the resolved + * object, so accepting it on any target would be a row restriction its author + * believes is in force and nothing applies. A contribution that leaves the key + * alone registers exactly as before. + * + * Reachable only by a caller that skips the parse — a direct + * `registerObject(def, pkg, undefined, 'extend')` call: the manifest path + * (`ObjectQL.registerApp`) copies a closed key list off each + * `objectExtensions` entry, and the entry's schema refuses the key at + * authoring. + * + * The ADR-0112 envelope (`code` + `status: 422`), never a bare throw. + */ +class ExtensionDefaultRowScopeRefusedError extends Error { + readonly code = EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** The object the extension targets. */ + readonly objectName: string; + /** The package whose `extend` contribution this refusal stopped. */ + readonly packageId: string | undefined; + + constructor(objectName: string, packageId: string | undefined) { + super( + `Object "${objectName}": the \`extend\` contribution from package "${packageId}" carries ` + + '`defaultRowScope`. An object\'s default row scope is declared only by the object\'s owner; an ' + + 'extension can neither set nor loosen it, and the extension merge never carries it onto the ' + + 'object. Remove `defaultRowScope` from the extension. To scope the rows a permission set\'s ' + + 'holders read, give that set a `rowLevelSecurity` policy for the object.', + ); + this.name = 'ExtensionDefaultRowScopeRefusedError'; + this.objectName = objectName; + this.packageId = packageId; + } +} + /** * [#16319] The registration-door field-`type` check — the single point of * closure the ruling names. @@ -3246,6 +3305,15 @@ export class SchemaRegistry { const fieldTypeRefusal = objectFieldTypeRefusal(schema); if (fieldTypeRefusal) throw fieldTypeRefusal; + // [ADR-0095 D1 note; ruling E′ item 4] An `extend` contribution never sets + // the object's default row scope — see + // {@link ExtensionDefaultRowScopeRefusedError}. Here, beside the field-`type` + // door and for the same reason: before any materialization and before the + // contributor list is opened, so a refused contribution leaves nothing behind. + if (ownership === 'extend' && (schema as { defaultRowScope?: unknown }).defaultRowScope !== undefined) { + throw new ExtensionDefaultRowScopeRefusedError(schema.name, packageId); + } + // [#23065] A `tree` field that names no `reference` references the object // that declares it, and this is where that is written down for every door // the parse does not cover: a metadata save stores the authored body diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 83c54a3109e..db488c6af2f 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -114,6 +114,20 @@ export const SysAccount = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_account_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'Account ID', diff --git a/packages/platform-objects/src/identity/sys-api-key.object.ts b/packages/platform-objects/src/identity/sys-api-key.object.ts index 4a2236efb37..7e0f85c84b1 100644 --- a/packages/platform-objects/src/identity/sys-api-key.object.ts +++ b/packages/platform-objects/src/identity/sys-api-key.object.ts @@ -227,6 +227,20 @@ export const SysApiKey = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_api_key_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { // ── Identity ───────────────────────────────────────────────── // The five fields below are `readonly` for one reason (ADR-0092 D4's diff --git a/packages/platform-objects/src/identity/sys-device-code.object.ts b/packages/platform-objects/src/identity/sys-device-code.object.ts index 4d129b6e8d2..dd80c97be38 100644 --- a/packages/platform-objects/src/identity/sys-device-code.object.ts +++ b/packages/platform-objects/src/identity/sys-device-code.object.ts @@ -51,6 +51,20 @@ export const SysDeviceCode = ObjectSchema.create({ titleFormat: '{user_code}', highlightFields: ['user_code', 'status', 'client_id', 'expires_at'], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_device_code_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'Device Code ID', diff --git a/packages/platform-objects/src/identity/sys-jwks.object.ts b/packages/platform-objects/src/identity/sys-jwks.object.ts index 1e081d5f0d1..faed80e07da 100644 --- a/packages/platform-objects/src/identity/sys-jwks.object.ts +++ b/packages/platform-objects/src/identity/sys-jwks.object.ts @@ -39,6 +39,20 @@ export const SysJwks = ObjectSchema.create({ description: 'Asymmetric key pairs used to sign and verify issued JWTs', highlightFields: ['id', 'created_at', 'expires_at'], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`privateCredentialRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_jwks_none', + operation: 'select', + using: 'id == null', + }, + fields: { id: Field.text({ label: 'Key ID', diff --git a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts index 7b74a940aa4..c53254e88c6 100644 --- a/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-access-token.object.ts @@ -38,6 +38,20 @@ export const SysOauthAccessToken = ObjectSchema.create({ description: 'Opaque OAuth access tokens issued to client applications', highlightFields: ['client_id', 'user_id', 'expires_at'], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_oauth_access_token_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', diff --git a/packages/platform-objects/src/identity/sys-oauth-application.object.ts b/packages/platform-objects/src/identity/sys-oauth-application.object.ts index 3cf3999c428..caf6e6d417c 100644 --- a/packages/platform-objects/src/identity/sys-oauth-application.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-application.object.ts @@ -284,6 +284,20 @@ export const SysOauthApplication = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_oauth_application_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { // ── Identity ───────────────────────────────────────────────── id: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts index 1d52de0efba..f9ff3c1dcf9 100644 --- a/packages/platform-objects/src/identity/sys-oauth-consent.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-consent.object.ts @@ -34,6 +34,20 @@ export const SysOauthConsent = ObjectSchema.create({ description: 'User consent records for OAuth client applications', highlightFields: ['client_id', 'user_id', 'scopes'], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_oauth_consent_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', diff --git a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts index 86b90fe955c..da63bf5fea9 100644 --- a/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts +++ b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts @@ -37,6 +37,20 @@ export const SysOauthRefreshToken = ObjectSchema.create({ description: 'Opaque OAuth refresh tokens (linked to a session)', highlightFields: ['client_id', 'user_id', 'expires_at'], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_oauth_refresh_token_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 06e71a1a9a6..6ccea718d4b 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -299,6 +299,20 @@ export const SysOrganization = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_organization_self', + operation: 'all', + using: 'id == current_user.organization_id', + }, + fields: { // ── Identity ───────────────────────────────────────────────── // ADR-0092 D4 — with the generic edit affordance open (#15873), every diff --git a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts index 92955d743c1..3e1a7602878 100644 --- a/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts @@ -51,6 +51,20 @@ export const SysScimConnectionBinding = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_connection_binding_none', + operation: 'select', + using: 'id == null', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts index df8191be574..1e21ef82ad6 100644 --- a/packages/platform-objects/src/identity/sys-scim-group-member.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group-member.object.ts @@ -57,6 +57,20 @@ export const SysScimGroupMember = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_group_member_none', + operation: 'select', + using: 'id == null', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-scim-group.object.ts b/packages/platform-objects/src/identity/sys-scim-group.object.ts index 0ff770b8224..0d20a8f1758 100644 --- a/packages/platform-objects/src/identity/sys-scim-group.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group.object.ts @@ -50,6 +50,20 @@ export const SysScimGroup = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_group_none', + operation: 'select', + using: 'id == null', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts index 02e1204b841..072d22280e6 100644 --- a/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-identity-tombstone.object.ts @@ -46,6 +46,20 @@ export const SysScimIdentityTombstone = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_identity_tombstone_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts index 570153ab931..b0a20e05365 100644 --- a/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-projection-grant.object.ts @@ -58,6 +58,20 @@ export const SysScimProjectionGrant = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_projection_grant_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-scim-subject.object.ts b/packages/platform-objects/src/identity/sys-scim-subject.object.ts index b3f5d61380c..e2645abe45e 100644 --- a/packages/platform-objects/src/identity/sys-scim-subject.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-subject.object.ts @@ -53,6 +53,20 @@ export const SysScimSubject = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_subject_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-scim-user.object.ts b/packages/platform-objects/src/identity/sys-scim-user.object.ts index a07ba11faaa..7689e222e15 100644 --- a/packages/platform-objects/src/identity/sys-scim-user.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-user.object.ts @@ -51,6 +51,20 @@ export const SysScimUser = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`scimProjectionRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_scim_user_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'ID', required: true, readonly: true, group: 'System' }), diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index aae18c1bd04..d91da28e014 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -150,6 +150,20 @@ export const SysSession = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_session_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { // ── Session owner & expiry ────────────────────────────────── user_id: Field.lookup('sys_user', { diff --git a/packages/platform-objects/src/identity/sys-team-member.object.ts b/packages/platform-objects/src/identity/sys-team-member.object.ts index 66fca16a9ff..c52b8b7d73a 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -95,6 +95,20 @@ export const SysTeamMember = ObjectSchema.create({ }, ], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_team_member_self', + operation: 'select', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'Team Member ID', diff --git a/packages/platform-objects/src/identity/sys-two-factor.object.ts b/packages/platform-objects/src/identity/sys-two-factor.object.ts index 7af88eeff9c..b142bf988ca 100644 --- a/packages/platform-objects/src/identity/sys-two-factor.object.ts +++ b/packages/platform-objects/src/identity/sys-two-factor.object.ts @@ -140,6 +140,20 @@ export const SysTwoFactor = ObjectSchema.create({ ], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert + // on this table (no organization column), so Layer 1 silence means this scope, not no + // filter. The security plugin's row-policy collection applies it when no set the + // caller holds carries a row policy for the object and operation; the shipped sets + // still carry it, so their holders read as before. An `extend` contribution cannot set + // or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_two_factor_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'Two Factor ID', diff --git a/packages/platform-objects/src/identity/sys-user-preference.object.ts b/packages/platform-objects/src/identity/sys-user-preference.object.ts index f20aebd1efe..bbafb52f5f8 100644 --- a/packages/platform-objects/src/identity/sys-user-preference.object.ts +++ b/packages/platform-objects/src/identity/sys-user-preference.object.ts @@ -70,6 +70,21 @@ export const SysUserPreference = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today (the `_self` + // carve-outs in `default-permission-sets.ts`), moved here verbatim. This table carries + // the organization column, so Layer 0 walls it to its organization and this scope + // applies inside that wall: Layer 1 silence means this scope, not no filter. The + // security plugin's row-policy collection applies it when no set the caller holds + // carries a row policy for the object and operation; the shipped sets still carry it, + // so their holders read as before. An `extend` contribution cannot set or loosen it + // (refused at registration). + defaultRowScope: { + name: 'sys_user_preference_self', + operation: 'all', + using: 'user_id == current_user.id', + }, + fields: { id: Field.text({ label: 'Preference ID', diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 7b992b6aa94..8236bca5fd5 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -724,6 +724,31 @@ export const SysUser = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: plugin-security's + // shipped permission sets carry TWO policies for it (`sys_user_self` and + // `sys_user_org_members` in `default-permission-sets.ts`), and the key holds one, so + // the two predicates are joined with `||` here: a principal reads its own row plus its + // organization's members. Layer 0 is inert on this table (no organization column), so + // Layer 1 silence means this scope, not no filter. `select`, the operation both arms + // share in the organization-admin and viewer sets: the peer arm must never reach a + // write, because OR-combined under `all` it would let a member edit any colleague's + // profile. The baseline set's self-service write scope (`sys_user_self` under `all`) + // stays the set's own policy. The rows are the union of the two arms whenever + // `current_user.org_user_ids` is resolved, which every user-context producer does + // (ruling A′ on #24449: the request's core resolver, and the seeding of a `runAs: + // 'user'` flow run and hook; `[userId]` with no organization); unresolved, which only + // a grants-resolver failure leaves (or a flow run suspended before its run setup + // seeded the list), the whole predicate fails closed, own row included. The security + // plugin's row-policy collection applies it when no set the caller holds carries a row + // policy for the object and operation; the shipped sets still carry both literals, so + // their holders read as before. An `extend` contribution cannot set or loosen it + // (refused at registration). + defaultRowScope: { + name: 'sys_user_self_or_org_members', + operation: 'select', + using: 'id == current_user.id || id in current_user.org_user_ids', + }, + fields: { // ── Identity (primary business fields) ─────────────────────── name: Field.text({ diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index fd42beec18d..488907889d2 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -53,6 +53,20 @@ export const SysVerification = ObjectSchema.create({ titleFormat: 'Verification', highlightFields: ['expires_at', 'created_at'], + // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy + // plugin-security's shipped permission sets spread for it today + // (`privateCredentialRowScope` in `default-permission-sets.ts`), moved here verbatim. + // Layer 0 is inert on this table (no organization column), so Layer 1 silence means + // this scope, not no filter. The security plugin's row-policy collection applies it + // when no set the caller holds carries a row policy for the object and operation; the + // shipped sets still carry it, so their holders read as before. An `extend` + // contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_verification_none', + operation: 'select', + using: 'id == null', + }, + fields: { id: Field.text({ label: 'Verification ID', diff --git a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts index fce54f2eb67..31d00458130 100644 --- a/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts +++ b/packages/plugins/plugin-audit/src/objects/sys-audit-log.object.ts @@ -143,6 +143,20 @@ export const SysAuditLog = ObjectSchema.create({ }, }, + // [ADR-0095 D1, 2026-10-18 note; ADR-0131 D7] This object's default row scope: the one + // policy plugin-security's shipped permission sets spread for it today + // (`auditLedgerRowScope` in `default-permission-sets.ts`), moved here verbatim. Layer + // 0 is inert on the ledger (`systemFields: { tenant: false }` above), so Layer 1 + // silence means this scope, not no filter. The security plugin's row-policy collection + // applies it when no set the caller holds carries a row policy for the object and + // operation; the shipped sets still carry it, so their holders read as before. An + // `extend` contribution cannot set or loosen it (refused at registration). + defaultRowScope: { + name: 'sys_audit_log_org', + operation: 'select', + using: 'tenant_id == current_user.organization_id', + }, + fields: { // ── Event ──────────────────────────────────────────────────── created_at: Field.datetime({ diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 5026a95585c..c443035af37 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -31,6 +31,7 @@ "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/plugin-audit": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", "@objectstack/service-i18n": "workspace:*", "@types/node": "^26.6.3", diff --git a/packages/plugins/plugin-security/src/object-default-row-scope.test.ts b/packages/plugins/plugin-security/src/object-default-row-scope.test.ts index 03c4d8b0b79..db89d1ddaaa 100644 --- a/packages/plugins/plugin-security/src/object-default-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/object-default-row-scope.test.ts @@ -176,7 +176,9 @@ interface Shape { } async function boot(shape: Shape) { - const identity = [SysUser, SysSession, SysScimUser].map((schema) => + // The identity objects declare their own defaults now (#24341): the + // undeclared shape drops the key, the declared one gives the fixture's. + const identity = [SysUser, SysSession, SysScimUser].map(({ defaultRowScope: _declared, ...schema }) => shape.declared ? { ...schema, defaultRowScope: IDENTITY_DEFAULTS[schema.name] } : schema, ); const notes = [ diff --git a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts new file mode 100644 index 00000000000..fcf26d47df8 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts @@ -0,0 +1,520 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0095 D1, 2026-10-18 note; ruling of record 6250748677 on #24164, letter + * E′ items 3 and 5] The platform identity objects declare their default row + * scope (`ObjectSchema.defaultRowScope`), and what each declares is the policy + * this package's shipped permission sets carry for that object today — read + * here off `defaultPermissionSets`, never retyped. + * + * Four pins: + * 1. The declarers are exactly the 23 objects the ruling names: every object + * `@objectstack/platform-objects` and `@objectstack/plugin-audit` register + * that declares a default is on the list, every listed object declares + * one, and the platform-global objects declare none (item 5). + * 2. Each declaration is the shipped literal with `object` dropped: same + * name, operation and predicate as the baseline set's policy for the + * object, and every shipped set that carries a policy for it carries the + * same predicate. + * 3. `sys_user` is the one merge: the key holds one policy, so its two + * shipped literals (`sys_user_self`, `sys_user_org_members`) are joined + * with `||`, and the merged predicate admits exactly the union of the two + * literals' rows whenever `org_user_ids` is resolved. The same holds on + * each user-context producer's real context: + * `service-automation/src/sys-user-default-row-scope.integration.test.ts`. + * 4. The security plugin's collection applies a declared default where the + * caller's held sets carry no row policy for the object and operation — + * never on a delegated principal's own leg — and the shipped sets keep + * their spreads: a deployment with the shipped baseline serves every + * persona, delegated agents included, the rows and the explain it served + * before the declarations. + */ + +import { describe, it, expect, vi, afterAll } from 'vitest'; +import * as PlatformObjects from '@objectstack/platform-objects'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { defaultPermissionSets } from './default-permission-sets.js'; +import { RLSCompiler } from '../rls-compiler.js'; +import { SecurityPlugin } from '../security-plugin.js'; +import { isObjectDefaultRowScopePolicy } from '../object-default-row-scope.js'; +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from '../__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './sys-user-position.object.js'; +import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; +import { bindTestSecurityCatalog } from '../__tests__/security-catalog.testkit.js'; + +type Policy = { + name: string; + object?: string; + operation: string; + using?: string; + check?: string; + positions?: string[]; + enabled?: boolean; +}; +type ObjectDef = { name: string; fields?: Record; defaultRowScope?: Policy }; + +/** The 23 declarers ruling E′ item 3 names, by the literal family that carries each today. */ +const DECLARERS = [ + // `scimProjectionRowScope` + 'sys_scim_user', + 'sys_scim_subject', + 'sys_scim_projection_grant', + 'sys_scim_identity_tombstone', + 'sys_scim_group', + 'sys_scim_group_member', + 'sys_scim_connection_binding', + // `privateCredentialRowScope` + 'sys_verification', + 'sys_jwks', + // the identity `_self` / `_org_members` carve-outs + 'sys_organization', + 'sys_user', + 'sys_session', + 'sys_account', + 'sys_team_member', + 'sys_two_factor', + 'sys_user_preference', + 'sys_api_key', + 'sys_device_code', + 'sys_oauth_access_token', + 'sys_oauth_refresh_token', + 'sys_oauth_consent', + 'sys_oauth_application', + // `auditLedgerRowScope` + 'sys_audit_log', +] as const; + +/** Ruling E′ item 5: the platform-global objects declare no default. */ +const PLATFORM_GLOBAL = /^(sys_metadata|sys_job|sys_migration)|^(sys_platform_setting|sys_presence|sys_flow_dispatch)$/; + +function isObjectDef(v: unknown): v is ObjectDef { + return ( + typeof v === 'object' && v !== null && typeof (v as ObjectDef).name === 'string' + && typeof (v as ObjectDef).fields === 'object' + ); +} + +/** The objects `AuditPlugin` registers — `sys_audit_log` is plugin-audit's (ADR-0029 K2). */ +async function auditPluginObjects(): Promise { + const registered: Array<{ objects?: ObjectDef[] }> = []; + const ctx: any = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + getService: (name: string) => + name === 'manifest' ? { register: (m: { objects?: ObjectDef[] }) => registered.push(m) } : undefined, + registerService() {}, + hook() {}, + }; + await new AuditPlugin().init(ctx); + return registered.flatMap((m) => m.objects ?? []); +} + +async function registeredObjects(): Promise> { + const byName = new Map(); + for (const v of Object.values(PlatformObjects)) if (isObjectDef(v)) byName.set(v.name, v); + for (const o of await auditPluginObjects()) byName.set(o.name, o); + return byName; +} + +const setNamed = (name: string) => { + const set = defaultPermissionSets.find((s) => s.name === name); + if (!set) throw new Error(`shipped set ${name} not found`); + return set; +}; +const policiesFor = (setName: string, object: string): Policy[] => + ((setNamed(setName).rowLevelSecurity ?? []) as Policy[]).filter((p) => p.object === object); + +/** The declaration as the shipped literal spells it: `object` implied, `enabled` defaulted. */ +const asLiteral = (object: string, scope: Policy): Policy => ({ ...scope, object, enabled: scope.enabled ?? true }); + +describe('the platform identity objects declare their default row scope (ruling E′ items 3 and 5)', () => { + it('the declarers are exactly the 23 the ruling names, and the platform-global objects declare none', async () => { + const objects = await registeredObjects(); + const declaring = [...objects.values()].filter((o) => o.defaultRowScope !== undefined).map((o) => o.name); + + expect([...declaring].sort()).toEqual([...DECLARERS].sort()); + expect(DECLARERS).toHaveLength(23); + + const platformGlobal = [...objects.keys()].filter((n) => PLATFORM_GLOBAL.test(n)); + // The census reached them: the item-5 list is not vacuous here. + expect(platformGlobal).toEqual(expect.arrayContaining(['sys_metadata', 'sys_job', 'sys_migration', 'sys_platform_setting'])); + for (const name of platformGlobal) expect(objects.get(name)!.defaultRowScope, name).toBeUndefined(); + }); + + it.each(DECLARERS.filter((n) => n !== 'sys_user'))( + '%s declares the baseline set\'s literal for it, with `object` dropped', + async (object) => { + const declared = (await registeredObjects()).get(object)!.defaultRowScope!; + const [baseline, ...more] = policiesFor('member_default', object); + expect(more).toEqual([]); + expect(asLiteral(object, declared)).toEqual(baseline); + expect('object' in declared).toBe(false); + + // The organization-admin set carries the same literal, and every shipped + // set that carries a policy for the object carries the same predicate. + expect(policiesFor('organization_admin', object)).toEqual([baseline]); + for (const set of defaultPermissionSets) { + for (const p of policiesFor(set.name, object)) { + expect({ set: set.name, name: p.name, using: p.using }).toEqual({ + set: set.name, + name: declared.name, + using: declared.using, + }); + } + } + }, + ); + + it('sys_user declares its two shipped literals as one `select` policy, the predicates joined with `||`', async () => { + const declared = (await registeredObjects()).get('sys_user')!.defaultRowScope!; + for (const setName of ['organization_admin', 'member_default', 'viewer_readonly']) { + const self = policiesFor(setName, 'sys_user').find((p) => p.name === 'sys_user_self')!; + const peers = policiesFor(setName, 'sys_user').find((p) => p.name === 'sys_user_org_members')!; + expect(declared.using).toBe(`${self.using} || ${peers.using}`); + // The peer arm is `select` in every set; the merged policy keeps it there. + expect(peers.operation).toBe('select'); + } + expect(declared).toMatchObject({ name: 'sys_user_self_or_org_members', operation: 'select' }); + expect(policiesFor('organization_admin', 'sys_user').map((p) => p.operation)).toEqual(['select', 'select']); + }); +}); + +/** + * The rows a compiled filter admits, over a fixture — the three shapes the two + * literals compile to (`{ id }`, `{ id: { $in } }`, `{ $or }`) and the deny + * filter, which is itself an `{ id }` no row carries. + */ +function admits(filter: Record | null, rows: Array<{ id: string }>): string[] { + const match = (f: Record, row: { id: string }): boolean => + Object.entries(f).every(([k, v]) => { + if (k === '$or') return (v as Record[]).some((g) => match(g, row)); + if (k === '$and') return (v as Record[]).every((g) => match(g, row)); + const cell = (row as Record)[k]; + if (v !== null && typeof v === 'object') { + const ops = v as Record; + if (Object.keys(ops).some((op) => op !== '$in' && op !== '$eq')) { + throw new Error(`fixture matcher: unhandled operator in ${JSON.stringify(ops)}`); + } + if ('$in' in ops && !(ops.$in as unknown[]).includes(cell)) return false; + if ('$eq' in ops && ops.$eq !== cell) return false; + return true; + } + return cell === v; + }); + if (filter === null) return rows.map((r) => r.id); + return rows.filter((r) => match(filter, r)).map((r) => r.id); +} + +describe('sys_user\'s merged predicate admits the union of its two literals\' rows (rider: the key is one policy)', () => { + const rows = ['u_me', 'u_peer_a', 'u_peer_b', 'u_stranger'].map((id) => ({ id })); + const contexts = { + 'peers resolved': { userId: 'u_me', tenantId: 'org_1', org_user_ids: ['u_me', 'u_peer_a', 'u_peer_b'] }, + 'peers resolved, self not among them': { userId: 'u_me', tenantId: 'org_1', org_user_ids: ['u_peer_a'] }, + 'no organization (the resolver seeds [])': { userId: 'u_me', org_user_ids: [] }, + }; + + it.each(Object.entries(contexts))('%s', async (_label, ctx) => { + const declared = (await registeredObjects()).get('sys_user')!.defaultRowScope!; + const merged = asLiteral('sys_user', declared) as any; + const literals = policiesFor('organization_admin', 'sys_user') as any[]; + const compiler = new RLSCompiler(); + + const mergedRows = admits(compiler.compileFilter([merged], ctx as any), rows); + const unionRows = [ + ...new Set(literals.flatMap((p) => admits(compiler.compileFilter([p], ctx as any), rows))), + ].sort(); + // Both literals, as the shipped sets OR-combine them, admit the same set. + expect(admits(compiler.compileFilter(literals, ctx as any), rows).sort()).toEqual(unionRows); + expect(mergedRows.sort()).toEqual(unionRows); + expect(mergedRows).toContain('u_me'); + expect(mergedRows).not.toContain('u_stranger'); + }); + + it('measured divergence: with `org_user_ids` unresolved the merged policy fails closed whole, own row included', async () => { + const declared = (await registeredObjects()).get('sys_user')!.defaultRowScope!; + const compiler = new RLSCompiler(); + const ctx = { userId: 'u_me', tenantId: 'org_1' } as any; + expect(admits(compiler.compileFilter([asLiteral('sys_user', declared) as any], ctx), rows)).toEqual([]); + // The two literals apart keep the self arm. + expect(admits(compiler.compileFilter(policiesFor('organization_admin', 'sys_user') as any, ctx), rows)).toEqual(['u_me']); + }); +}); + +/** + * [#24164 ruling E′ item 6; #24340's review of record, ③.11] A deployment + * with the shipped baseline serves byte-for-byte what it served before the + * identity objects declared their defaults, now that the security plugin's + * collection applies a declared default wherever the caller's held sets carry + * no row policy for the object and operation. + * + * Booted on the real `SecurityPlugin` + `ObjectQL` over a real `SqlDriver`, + * with every declarer as this tree declares it ("after") and the same objects + * with `defaultRowScope` dropped ("before"), the shipped sets, and the + * platform baseline composed, and a catalog the grants resolve against. Each + * persona — the humans, and an MCP agent acting for each of four delegators — + * reads every declarer, and is explained, identically in both. The census + * reads the collection for all four operations: no baseline holder collects a + * default, and no delegated agent's own leg does either (ruling of record + * 6278784335 on #24341, letter B: a ceiling's silence is not Layer 1 silence, + * so a platform administrator's agent keeps every row its delegator reads). + */ + +type Persona = Record; + +/** Organisation X: `u_x` and `u_x2`. Organisation Y: `u_y`. `u_loner` belongs to none. */ +const ORG_X_USERS = ['u_x', 'u_x2']; +const BASELINE_USERS = ['u_x', 'u_x2', 'u_y', 'u_loner', 'a_x', 'p_admin']; + +const BASELINE_PERSONAS: Record = { + member: { + userId: 'u_x', tenantId: 'org_x', positions: ['org_member'], permissions: ['member_default'], + posture: 'MEMBER', org_user_ids: ORG_X_USERS, email: 'u_x@example.test', + }, + viewer: { + userId: 'u_x2', tenantId: 'org_x', positions: ['org_member'], permissions: ['viewer_readonly'], + posture: 'MEMBER', org_user_ids: ORG_X_USERS, email: 'u_x2@example.test', + }, + organization_admin: { + userId: 'a_x', tenantId: 'org_x', positions: ['org_admin'], permissions: ['organization_admin'], + posture: 'TENANT_ADMIN', org_user_ids: [...ORG_X_USERS, 'a_x'], email: 'a_x@example.test', + }, + // The resolver's answer for a user in no organisation: no positions, no + // sets of their own, the peer list seeded with themself alone. + 'user with no organisation': { + userId: 'u_loner', positions: [], permissions: [], posture: 'MEMBER', org_user_ids: ['u_loner'], + email: 'u_loner@example.test', + }, + platform_admin: { + userId: 'p_admin', tenantId: 'org_x', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN', + }, + // An MCP OAuth agent as core's `assembleExecutionContext` shapes it: the + // ceiling on `permissions`, `userId` the delegating member (ADR-0090 D10). + 'mcp agent for the member': { + userId: 'u_x', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent', + onBehalfOf: { userId: 'u_x', principalKind: 'human' }, posture: 'MEMBER', org_user_ids: ORG_X_USERS, + email: 'u_x@example.test', + }, + 'mcp agent for the viewer': { + userId: 'u_x2', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent', + onBehalfOf: { userId: 'u_x2', principalKind: 'human' }, posture: 'MEMBER', org_user_ids: ORG_X_USERS, + email: 'u_x2@example.test', + }, + 'mcp agent for the organization admin': { + userId: 'a_x', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent', + onBehalfOf: { userId: 'a_x', principalKind: 'human' }, posture: 'TENANT_ADMIN', org_user_ids: [...ORG_X_USERS, 'a_x'], + email: 'a_x@example.test', + }, + 'mcp agent for the platform admin': { + userId: 'p_admin', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent', + onBehalfOf: { userId: 'p_admin', principalKind: 'human' }, posture: 'PLATFORM_ADMIN', org_user_ids: ['p_admin'], + email: 'p_admin@example.test', + }, +}; + +/** A plausible value for a required column the fixture does not care about. */ +function filler(type: string, key: string): unknown { + if (type === 'boolean') return true; + if (type === 'number' || type === 'currency' || type === 'percent') return 0; + if (type === 'datetime' || type === 'date') return '2026-01-01T00:00:00.000Z'; + return key; +} + +/** One row of `schema`: every required column filled, then those of `values` it declares. */ +function rowOf(schema: ObjectDef, values: Record): Record { + const fields = (schema.fields ?? {}) as Record; + const out: Record = {}; + for (const [name, def] of Object.entries(fields)) if (def.required) out[name] = filler(def.type, `${name}-${String(values.id)}`); + for (const [name, value] of Object.entries(values)) if (name === 'id' || name in fields) out[name] = value; + return out; +} + +/** The rows written for `object`: one per user, each user's own where it names one, in that user's organisation. */ +function fixtureRowsFor(schema: ObjectDef): Array> { + if (schema.name === 'sys_user') { + return BASELINE_USERS.map((id) => rowOf(schema, { id, name: id, email: `${id}@example.test` })); + } + if (schema.name === 'sys_organization') { + return ['org_x', 'org_y'].map((id) => rowOf(schema, { id, name: id, slug: id })); + } + const orgOf = (user: string) => (user === 'u_y' ? 'org_y' : user === 'u_loner' || user === 'p_admin' ? null : 'org_x'); + return BASELINE_USERS.map((user) => + rowOf(schema, { id: `${schema.name}:${user}`, user_id: user, organization_id: orgOf(user), actor_id: user }), + ); +} + +const baselineEngines: ObjectQL[] = []; +afterAll(async () => { + for (const engine of baselineEngines.splice(0)) { + try { await engine.destroy(); } catch { /* noop */ } + } +}); + +async function bootBaseline(declared: boolean) { + const objects = await registeredObjects(); + const declarers = DECLARERS.map((name) => { + const { defaultRowScope: _declared, ...schema } = objects.get(name)!; + return (declared ? objects.get(name)! : schema) as ObjectDef; + }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + baselineEngines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.identity-default-row-scope-baseline', + name: 'Identity default row scope baseline', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [...declarers, objects.get('sys_member')!, SysUserPermissionSet, SysUserPosition], + } as never); + await engine.syncSchemas(); + for (const o of [SysPosition, SysPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + + const services: Record = { + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + tenancy: { posture: 'isolated' }, + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async (_t: string, name: string) => engine.getSchema(name) ?? null, list: async () => defaultPermissionSets }, + }; + let security: any; + const ctx = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + registerService: (name: string, impl: unknown) => { if (name === 'security') security = impl; }, + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + // The shipped sets, the platform baseline composed: the stock deployment. + const plugin = new SecurityPlugin({ defaultPermissionSets }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The catalog the grants name their sets in (ADR-0131 D3/D4): the shipped + // sets, so a delegator's grant resolves to the set it names. + bindTestSecurityCatalog(engine, { permissions: defaultPermissionSets as never }); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + const knex = (driver as unknown as { knex: (t: string) => { insert(rows: object[]): Promise } }).knex; + for (const schema of declarers) await knex(schema.name).insert(fixtureRowsFor(schema)); + // What an agent's delegator resolves from (ADR-0090 D10: the delegator leg is + // rebuilt from the store): the memberships, and the grants the viewer and the + // two administrators hold. + const member = objects.get('sys_member')!; + await knex('sys_member').insert([ + ['u_x', 'org_x', 'member'], ['u_x2', 'org_x', 'member'], ['a_x', 'org_x', 'admin'], ['u_y', 'org_y', 'member'], + ].map(([user, org, role]) => rowOf(member, { id: `mem:${user}`, user_id: user, organization_id: org, role }))); + await knex('sys_user_permission_set').insert([ + { id: 'ups:u_x2', user_id: 'u_x2', permission_set: 'viewer_readonly', organization_id: 'org_x' }, + { id: 'ups:a_x', user_id: 'a_x', permission_set: 'organization_admin', organization_id: 'org_x' }, + { id: 'ups:p_admin', user_id: 'p_admin', permission_set: 'admin_full_access', organization_id: null }, + ]); + + const served = async (object: string, persona: Persona): Promise => { + try { + const rows = (await engine.find(object, { context: { ...persona } } as never)) as Array<{ id: string }>; + return rows.map((r) => r.id).sort(); + } catch (e) { + const err = e as { code?: string; status?: number }; + return `refused ${err.code} ${err.status}`; + } + }; + return { engine, plugin: plugin as any, security, served }; +} + +describe('a deployment with the shipped baseline is byte-for-byte unchanged on the real declarations (ruling E′ item 6, ③.11)', () => { + // Explain is read for `read`; the census below reads the collection itself, + // the one source every door and every explain composes from, for all four. + const EXPLAINED = ['read'] as const; + const COLLECTED = ['select', 'insert', 'update', 'delete'] as const; + + it('every persona reads every declarer exactly as without the declarations, and is explained the same', { timeout: 30_000 }, async () => { + const after = await bootBaseline(true); + const before = await bootBaseline(false); + const rowsMoved: string[] = []; + const explainMoved: string[] = []; + for (const [persona, context] of Object.entries(BASELINE_PERSONAS)) { + for (const object of DECLARERS) { + const label = `${persona} on ${object}`; + const was = await before.served(object, context); + const now = await after.served(object, context); + if (JSON.stringify(now) !== JSON.stringify(was)) rowsMoved.push(`${label}: ${JSON.stringify(was)} -> ${JSON.stringify(now)}`); + for (const operation of EXPLAINED) { + const e0 = JSON.stringify(await before.security.explain({ object, operation }, { ...context })); + const e1 = JSON.stringify(await after.security.explain({ object, operation }, { ...context })); + if (e1 !== e0) explainMoved.push(`${label} (${operation})`); + } + } + } + expect(rowsMoved).toEqual([]); + expect(explainMoved).toEqual([]); + // CONTROL: the comparison has teeth — the baseline narrows each persona, + // the fixture holds rows they must not read, and the platform administrator + // reads them all. + expect(await after.served('sys_user', BASELINE_PERSONAS.member)).toEqual(['u_x', 'u_x2']); + expect(await after.served('sys_session', BASELINE_PERSONAS['user with no organisation'])).toEqual(['sys_session:u_loner']); + expect(await after.served('sys_session', BASELINE_PERSONAS.platform_admin)).toHaveLength(BASELINE_USERS.length); + }); + + it('each delegated agent reads its delegator\'s rows on both trees: the platform administrator\'s agent reads every row', { timeout: 30_000 }, async () => { + const after = await bootBaseline(true); + const before = await bootBaseline(false); + const everyUser = [...BASELINE_USERS].sort(); + const expected: Record = { + 'mcp agent for the member': { sys_user: ['u_x', 'u_x2'], sys_session: ['sys_session:u_x'], sys_scim_user: ['sys_scim_user:u_x'] }, + 'mcp agent for the viewer': { sys_user: ['u_x', 'u_x2'], sys_session: ['sys_session:u_x2'], sys_scim_user: ['sys_scim_user:u_x2'] }, + 'mcp agent for the organization admin': { + sys_user: ['a_x', 'u_x', 'u_x2'], sys_session: ['sys_session:a_x'], sys_scim_user: ['sys_scim_user:a_x'], + }, + // The delegator's leg skips Layer 1 (the superuser bypass on these + // objects), so the only bound a declared default could add is the + // ceiling's own leg — which collects none (letter B). + 'mcp agent for the platform admin': { + sys_user: everyUser, + sys_session: everyUser.map((u) => `sys_session:${u}`), + sys_scim_user: everyUser.map((u) => `sys_scim_user:${u}`), + }, + }; + for (const [persona, objects] of Object.entries(expected)) { + for (const [object, ids] of Object.entries(objects)) { + expect(await before.served(object, BASELINE_PERSONAS[persona]), `${persona} on ${object}, before`).toEqual(ids); + expect(await after.served(object, BASELINE_PERSONAS[persona]), `${persona} on ${object}, after`).toEqual(ids); + } + } + }); + + it('census: no persona collects a declared default — the baseline carries a policy for each, and a delegated leg collects none', async () => { + const { plugin } = await bootBaseline(true); + const objects = await registeredObjects(); + const census = async (context: Persona): Promise => { + const sets = await plugin.resolvePermissionSetsForContext({ ...context }); + const applied: string[] = []; + for (const object of DECLARERS) { + for (const operation of COLLECTED) { + const collected = plugin.collectRLSPolicies(sets, object, operation, (context.positions ?? []) as string[], { ...context }); + if (collected.some((p: object) => isObjectDefaultRowScopePolicy(p))) applied.push(`${object} ${operation}`); + } + } + return applied; + }; + for (const [persona, context] of Object.entries(BASELINE_PERSONAS)) { + // The platform administrator's superuser bypass is decided before the collection. + if (persona === 'platform_admin') continue; + expect(await census(context), persona).toEqual([]); + } + // CONTROL: the carve-out is the delegation, not the agent kind. The same + // ceiling with no delegator (a hand-built shape no producer emits) has a + // silent Layer 1, so it collects each declarer's default, on exactly the + // operations the declaration names (ruling E′ item 2, whole). + const { onBehalfOf: _delegator, ...undelegated } = BASELINE_PERSONAS['mcp agent for the member']; + const expected = DECLARERS.flatMap((object) => { + const scope = objects.get(object)!.defaultRowScope!; + return COLLECTED.filter((op) => scope.operation === 'all' || scope.operation === op).map((op) => `${object} ${op}`); + }); + expect((await census(undelegated)).sort()).toEqual(expected.sort()); + }); +}); diff --git a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts index e37af2446ad..5c6aeb17a5e 100644 --- a/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/scim-projection-row-scope.test.ts @@ -263,8 +263,12 @@ const PERSONAS = { // The MCP write ceiling holds the grant too; it carries no row policy of its // own by design (ADR-0090 D10), so its bound is the delegating member's sets. // Those resolve from the baseline, so this persona runs where one is composed. + // Shaped as its one producer shapes it (core's `assembleExecutionContext`): + // `userId` stays the delegating human, so `current_user.*` resolves to them on + // the ceiling's leg too, where each SCIM table's declared default row scope + // applies because the ceiling carries no policy (#24164 ruling E′ item 2). mcp_agent_data_write: { - userId: 'agent_x', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_write'], + userId: PEOPLE.member, tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_write'], principalKind: 'agent', onBehalfOf: { userId: PEOPLE.member }, org_user_ids: ORG_X_USERS, }, } as const; diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 1e3792351de..d31b3ba9c4d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -389,6 +389,24 @@ function isPrincipalLessContext(context: any): boolean { return positions.length === 0 && explicitPermissionSets.length === 0 && !context?.userId; } +/** + * [ADR-0090 D10; ruling of record 6278784335 on #24341, letter B] Whether + * `context` is a DELEGATED principal's own leg — an agent acting on behalf of + * a user, whose sets are its ceiling and whose row bound is the delegator's + * leg, AND-ed in beside it. The spelling is the read door's own test for the + * delegated case (`onBehalfOf.userId`); the delegator's leg runs on the + * context `resolveDelegatorContext` rebuilt, which carries none. + * + * Read by {@link SecurityPlugin.collectRLSPolicies} alone: a ceiling's silence + * is not Layer 1 silence, so the object's declared default row scope is not + * collected on that leg. Every row bound comes from the delegator, as before + * the identity objects declared their defaults. + */ +function isDelegatedPrincipalLeg(context: unknown): boolean { + const onBehalfOf = (context as { onBehalfOf?: { userId?: unknown } } | null | undefined)?.onBehalfOf; + return typeof onBehalfOf?.userId === 'string' && onBehalfOf.userId !== ''; +} + /** * [#21908] ADR-0096 D5 strict mode — the refusal the engine middleware throws * for a principal-less, non-system context ({@link isPrincipalLessContext}): @@ -5328,6 +5346,7 @@ export class SecurityPlugin implements Plugin { object, rlsOperation, (context?.positions ?? []) as string[], + context, ).some(isPlatformOwnershipFloorPolicy); if (!floorApplies) return false; return ( @@ -5450,6 +5469,7 @@ export class SecurityPlugin implements Plugin { object, operation, (context?.positions ?? []) as string[], + context, ).filter((p) => !isPlatformOwnershipFloorPolicy(p) && policyDeclaresClause(p, 'using')); if (authored.length === 0) return 'abstain'; @@ -7219,7 +7239,7 @@ export class SecurityPlugin implements Plugin { // longer skip the tenant wall (that is Layer 0's own exemption, below). let layer1: Record | null = null; if (!(posturePermits && superuserBypass)) { - let collected = this.collectRLSPolicies(permissionSets, object, operation, (context?.positions ?? []) as string[]); + let collected = this.collectRLSPolicies(permissionSets, object, operation, (context?.positions ?? []) as string[], context); // [#8023] An object whose author DECLARED `sharingModel: // 'public_read_write'` does not inherit the platform's wildcard `update` // ownership floor AT ALL. QA #7637 measured the contradiction it caused @@ -7366,7 +7386,7 @@ export class SecurityPlugin implements Plugin { this.permissionEvaluator.hasSuperuserReadBypass(object, permissionSets, { isPrivate: meta.isPrivate }) ) ) { - collected = this.collectRLSPolicies(permissionSets, object, 'select', (context?.positions ?? []) as string[]); + collected = this.collectRLSPolicies(permissionSets, object, 'select', (context?.positions ?? []) as string[], context); } // [#5492] Provenance composition: the caller (the by-id write pre-image // gate) has already asked the declared write authority — `ISharingService` @@ -7659,6 +7679,7 @@ export class SecurityPlugin implements Plugin { object, operation, (context?.positions ?? []) as string[], + context, ), { keepOwnershipFloor: !floorReplaced }, ); @@ -8909,6 +8930,7 @@ export class SecurityPlugin implements Plugin { rel.master, 'update', (context?.positions ?? []) as string[], + context, ).some(isPlatformOwnershipFloorPolicy); const dropPlatformOwnershipFloor = masterFloorApplies ? (await this.resolveSharingWriteVerdict( @@ -9013,13 +9035,20 @@ export class SecurityPlugin implements Plugin { * authored policy's do; * - a superuser-bypass principal never reaches this method (the bypass is * decided by the caller, before the collection), and an object that - * declares no default collects exactly what it collected before. + * declares no default collects exactly what it collected before; + * - [ruling 6278784335 on #24341, letter B] a delegated principal's own + * leg ({@link isDelegatedPrincipalLeg} on `principal`, the context the + * collection is for) never collects the default: its sets are a + * capability ceiling carrying no row policy (ADR-0090 D10), and its row + * bound is the delegator's leg, which collects the default as any + * principal's does. */ private collectRLSPolicies( permissionSets: PermissionSet[], objectName: string, operation: string, - heldPositions?: string[], + heldPositions: string[] | undefined, + principal: unknown, ): RowLevelSecurityPolicy[] { const allPolicies: RowLevelSecurityPolicy[] = []; const floorAlternates = this.ownershipFloorAlternates.all(); @@ -9039,7 +9068,7 @@ export class SecurityPlugin implements Plugin { } const held = this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies, heldPositions); - if (held.length > 0) return held; + if (held.length > 0 || isDelegatedPrincipalLeg(principal)) return held; // [#24164 ruling E′] The held sets are silent on this object and // operation: the object's own `defaultRowScope`, if it declares one. Read // from the registry the engine compiles every find against, only here, on diff --git a/packages/plugins/plugin-security/vitest.config.ts b/packages/plugins/plugin-security/vitest.config.ts index 5d45b0ee5c0..9260f2164ee 100644 --- a/packages/plugins/plugin-security/vitest.config.ts +++ b/packages/plugins/plugin-security/vitest.config.ts @@ -84,6 +84,14 @@ export default defineConfig({ // a BUILD ARTIFACT, and a dist merely BEHIND runs GREEN against the // driver's old behaviour while saying nothing (`check:test-source-alias` // refuses exactly that). + // `default-row-scope-declarations.test.ts` reads `sys_audit_log`'s declared + // default row scope off the object `AuditPlugin` registers (the ledger is + // plugin-audit's, ADR-0029 K2), so that declaration must be the source in + // this checkout, not a `dist/` that may predate it. + { + find: /^@objectstack\/plugin-audit$/, + replacement: path.resolve(__dirname, '../plugin-audit/src/index.ts'), + }, { find: /^@objectstack\/driver-sqlite-wasm$/, replacement: path.resolve(__dirname, '../../drivers/driver-sqlite-wasm/src/index.ts'), diff --git a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts new file mode 100644 index 00000000000..9b54bb93f69 --- /dev/null +++ b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts @@ -0,0 +1,330 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24164 ruling E′; ruling A′ on #24449] `sys_user` declares ONE default row + * scope, its two shipped literals joined with `||`: + * `id == current_user.id || id in current_user.org_user_ids`. That one + * predicate keeps the reader's own row only where the context carries + * `org_user_ids` — unresolved, the whole predicate drops, own row included, + * where the two literals apart kept the self arm. Ruling A′ puts the list on + * every user-context producer instead of splitting the key, so this pins the + * premise on each producer that reads `sys_user` as a user: + * - a request (the core resolver, as `resolveAuthzContext` copies it); + * - a `runAs: 'user'` flow run (seeded at run setup); + * - a `runAs: 'user'` hook fired from a hand-built system context (seeded + * where the hook's `runAs` is resolved). + * Each must read the user's own row, plus the members of the organisation the + * context names, under the declared default — and exactly the rows the two + * shipped literals admit together on that same context. + * + * The default applies only where the caller's sets carry no row policy for + * `sys_user`. Every human holds the platform baseline (`member_default`), + * which carries both literals, so this deployment composes no baseline and + * grants one authored `'*'` read set with no row policy of its own — the + * ruling's no-baseline principal. Explain names the object default, so the + * rows read are the declaration's, not a set's. + * + * The REAL stack, as its two twins beside it compose it: ObjectKernel, + * ObjectQLPlugin, driver-sql on better-sqlite3 `:memory:`, the identity + * objects as `@objectstack/platform-objects` declares them, the real + * SecurityPlugin and the real AutomationEngine with its CRUD nodes. + * + * Fixture: organisation A (the Default Organization) and organisation B. + * `usr_me` belongs to BOTH and acts in A; `usr_peer` belongs to A; + * `usr_other` belongs to B only; `usr_loner` belongs to none. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectKernel, resolveUserAuthzGrants, type PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin, bindHooksToEngine, type ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; +import { RLSCompiler, SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; +import { PermissionSetSchema, type PermissionSet } from '@objectstack/spec/security'; +import { AutomationServicePlugin } from './plugin.js'; +import type { AutomationEngine } from './engine.js'; + +const SYS = { isSystem: true } as const; +const silentLogger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }; + +/** The ruling's principal: an authored `'*'` read set that carries no row policy. */ +const STAR_READER = PermissionSetSchema.parse({ + name: 'qa_star_reader', + label: 'Star reader', + objects: { '*': { allowRead: true, allowCreate: true } }, +}) as PermissionSet; + +/** The two shipped literals the declaration merges, read off the baseline set (never retyped). */ +const SHIPPED_LITERALS = (securityDefaultPermissionSets.find((s) => s.name === 'member_default')?.rowLevelSecurity ?? []) + .filter((p) => p.object === 'sys_user' && (p.name === 'sys_user_self' || p.name === 'sys_user_org_members')); + +/** The object whose insert fires the hook under test. */ +const probeObject = { + name: 'hook_probe', + label: 'Hook probe', + tenancy: { enabled: false }, + fields: { name: { name: 'name', label: 'Name', type: 'text' as const } }, +}; + +/** start → get_record(sys_user, many) → end, under runAs:'user'. */ +const directoryFlow = { + name: 'directory', + label: 'directory', + type: 'autolaunched', + runAs: 'user', + variables: [{ name: 'users', type: 'object', isOutput: true }], + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'read', + type: 'get_record', + label: 'Read directory', + config: { objectName: 'sys_user', fields: ['id', 'name'], limit: 50, outputVariable: 'users' }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'read' }, + { id: 'e2', source: 'read', target: 'end' }, + ], +}; + +describe("sys_user's one `||` default keeps the reader's own row on every user-context producer", () => { + let kernel: ObjectKernel; + let ql: ObjectQL; + let automation: AutomationEngine; + let orgA = ''; + const orgB = 'org_b'; + /** What the hook's `sys_user` read answered, per firing. */ + let hookSeen: string[][] = []; + /** The context each non-system `sys_user` read carried to the middleware chain. */ + let readContexts: Array> = []; + + afterEach(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + const ids = (rows: unknown): string[] => (Array.isArray(rows) ? rows.map((r: any) => String(r.id)).sort() : []); + + async function boot() { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin({ + defaultPermissionSets: [...securityDefaultPermissionSets, STAR_READER], + fallbackPermissionSet: null, + })); + await kernel.bootstrap(); + + ql = kernel.getService('objectql'); + automation = kernel.getService('automation'); + ql.registry.registerObject(probeObject as any); + await (ql as any).syncObjectSchema?.('hook_probe'); + + const orgs = await ql.find('sys_organization', { context: SYS }); + expect(orgs.map((o: any) => o.slug)).toEqual(['default']); + orgA = String(orgs[0].id); + await ql.insert('sys_organization', { id: orgB, name: 'Organisation B', slug: 'org-b' }, { context: SYS }); + + for (const [id, name] of [['usr_me', 'Me'], ['usr_peer', 'Peer'], ['usr_other', 'Other'], ['usr_loner', 'Loner']]) { + await ql.insert('sys_user', { id, name, email: `${id}@example.test` }, { context: SYS }); + } + const memberships: Array<[string, string, string]> = [ + ['mem_me_a', 'usr_me', orgA], + ['mem_me_b', 'usr_me', orgB], + ['mem_peer_a', 'usr_peer', orgA], + ['mem_other_b', 'usr_other', orgB], + ]; + for (const [id, userId, organizationId] of memberships) { + await ql.insert('sys_member', { id, user_id: userId, organization_id: organizationId, role: 'member' }, { context: SYS }); + } + // The one grant: a global (organisation-less) assignment of the `'*'` reader, + // written straight into the table — a system write here must name one of + // the two organisations, and a global grant names none. + await (driver as unknown as { knex: (t: string) => { insert(r: object): Promise } }) + .knex('sys_user_permission_set') + .insert({ id: 'ups_me', user_id: 'usr_me', permission_set: STAR_READER.name, organization_id: null }); + + hookSeen = []; + readContexts = []; + ql.registerMiddleware(async (opCtx: any, next: any) => { + if (opCtx.object === 'sys_user' && opCtx.context && !opCtx.context.isSystem) readContexts.push({ ...opCtx.context }); + return next(); + }); + bindHooksToEngine(ql, [ + { + name: 'read_directory', + object: 'hook_probe', + events: ['afterInsert'], + priority: 100, + runAs: 'user', + handler: async (ctx: any) => { + hookSeen.push(ids(await ctx.api.object('sys_user').find({ fields: ['id', 'name'] }))); + }, + } as any, + ], { packageId: 'app:test', logger: silentLogger }); + } + + /** The context the request path hands ObjectQL: `resolveAuthzContext` copies exactly these grants onto it. */ + async function requestContext(userId: string, tenantId?: string) { + const grants = await resolveUserAuthzGrants(ql, userId, { tenantId, bypassGrantsCache: true }); + return { + userId, + ...(tenantId ? { tenantId } : {}), + isSystem: false, + positions: grants.positions, + permissions: grants.permissions, + systemPermissions: grants.systemPermissions, + org_user_ids: grants.org_user_ids, + accessible_org_ids: grants.accessible_org_ids, + }; + } + + const producers: Record Promise> = { + request: async (tenantId) => + ids(await ql.find('sys_user', { fields: ['id', 'name'], context: await requestContext('usr_me', tenantId) })), + "runAs:'user' flow": async (tenantId) => { + automation.registerFlow('directory', directoryFlow as any); + const res = await automation.execute('directory', { userId: 'usr_me', ...(tenantId ? { tenantId } : {}) } as any); + expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); + return ids((res.output as { users?: unknown } | undefined)?.users); + }, + "runAs:'user' hook, hand-built system context": async (tenantId) => { + const before = hookSeen.length; + await ql.insert('hook_probe', { name: 'p' }, { context: { isSystem: true, userId: 'usr_me', ...(tenantId ? { tenantId } : {}) } as any }); + expect(hookSeen.length, 'the hook ran exactly once').toBe(before + 1); + return hookSeen[hookSeen.length - 1]; + }, + }; + + /** The rows the two shipped literals admit together on `context`, each compiled apart and read past every scope. */ + async function unionOfLiterals(context: Record): Promise { + const compiler = new RLSCompiler(); + const out = new Set(); + for (const literal of SHIPPED_LITERALS) { + const where = compiler.compileFilter([literal as any], context as any); + if (where === null) throw new Error(`literal ${literal.name} compiled to no filter`); + for (const id of ids(await ql.find('sys_user', { where, fields: ['id'], context: SYS }))) out.add(id); + } + return [...out].sort(); + } + + it('the premise: the two shipped literals the declaration merges are present and spelled as it joins them', async () => { + await boot(); + expect(SHIPPED_LITERALS.map((p) => p.name).sort()).toEqual(['sys_user_org_members', 'sys_user_self']); + const declared = (ql.registry.getObject('sys_user') as any).defaultRowScope; + const [self, peers] = ['sys_user_self', 'sys_user_org_members'].map((n) => SHIPPED_LITERALS.find((p) => p.name === n)!); + expect(declared).toMatchObject({ name: 'sys_user_self_or_org_members', operation: 'select', using: `${self.using} || ${peers.using}` }); + }); + + for (const [producer, read] of Object.entries(producers)) { + it(`${producer}, acting in organisation A: its own row and A's members, nothing beyond, and exactly the literals' union`, async () => { + await boot(); + expect(await read(orgA)).toEqual(['usr_me', 'usr_peer']); + const context = readContexts[readContexts.length - 1]; + expect(Array.isArray(context.org_user_ids), 'the read carries org_user_ids').toBe(true); + expect(await unionOfLiterals(context)).toEqual(['usr_me', 'usr_peer']); + }); + + it(`${producer}, acting in no organisation: its own row alone, and exactly the literals' union`, async () => { + await boot(); + expect(await read(undefined)).toEqual(['usr_me']); + const context = readContexts[readContexts.length - 1]; + expect(context.org_user_ids).toEqual(['usr_me']); + expect(await unionOfLiterals(context)).toEqual(['usr_me']); + }); + } + + it('CONTROL: the rows are the object default\'s — explain names it, and a system read sees all four users', async () => { + await boot(); + expect(ids(await ql.find('sys_user', { context: SYS }))).toEqual(['usr_loner', 'usr_me', 'usr_other', 'usr_peer']); + type Explained = { layers: Array<{ layer: string; verdict: string; detail: string }> }; + const security = kernel.getService<{ explain(input: { object: string; operation: string }, context: object): Promise }>('security'); + const decision = await security.explain({ object: 'sys_user', operation: 'read' }, await requestContext('usr_me', orgA)); + const rls = decision.layers.find((l) => l.layer === 'rls'); + expect(rls?.verdict).toBe('narrows'); + expect(rls?.detail).toContain("'sys_user_self_or_org_members'"); + expect(rls?.detail).toContain('object default row scope'); + }); +}); + +/** + * [#24164 ruling E′ item 4] On the same booted composition: the one shipped + * `extend` of a platform object in this repository — plugin-auth's + * `sys_sso_provider` extension (`authObjectExtensions`, registered by the + * identity objects plugin) — still registers, and a contribution that sets + * `defaultRowScope` on a platform object is refused by name, leaving the + * object's own declaration in force. + */ +describe('the registry\'s `extend` rule on a booted composition', () => { + let kernel: ObjectKernel; + afterEach(async () => { + try { await kernel?.shutdown(); } catch { /* noop */ } + }); + + async function bootRegistry(): Promise { + kernel = new ObjectKernel({ logger: { level: 'fatal' } }); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await kernel.use({ + name: 'test.driver.sqlite', + type: 'standard', + version: '1.0.0', + async init(ctx: PluginContext) { + ctx.registerService('driver.default', driver); + }, + }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' })); + await kernel.use(createIdentityObjectsPlugin()); + await kernel.use(new SecurityPlugin()); + await kernel.bootstrap(); + return kernel.getService('objectql'); + } + + it('every `extend` contribution the composition registers is plugin-auth\'s on sys_sso_provider, and it folded', async () => { + const ql = await bootRegistry(); + const extensions = ql.registry.getAllObjects().flatMap((o: any) => + ql.registry.getObjectContributors(o.name) + .filter((c: any) => c.ownership === 'extend') + .map((c: any) => `${c.packageId} -> ${o.name}`), + ); + expect(extensions).toEqual(['com.objectstack.plugin-auth -> sys_sso_provider']); + expect(Object.keys((ql.registry.getObject('sys_sso_provider') as any).fields)).toContain('oidc_client_secret'); + }); + + it('a contribution that sets `defaultRowScope` on sys_user is refused by name; the declared default stands', async () => { + const ql = await bootRegistry(); + const declared = (ql.registry.getObject('sys_user') as any).defaultRowScope; + expect(declared?.name).toBe('sys_user_self_or_org_members'); + const err = (() => { + try { + ql.registry.registerObject( + { name: 'sys_user', defaultRowScope: { name: 'everyone', operation: 'select', using: 'id != null' }, fields: {} } as any, + 'app.loosener', + undefined, + 'extend', + ); + } catch (e) { + return e as { code?: string; status?: number; name?: string }; + } + return undefined; + })(); + expect({ code: err?.code, status: err?.status, name: err?.name }).toEqual({ + code: 'INVALID_METADATA', + status: 422, + name: 'ExtensionDefaultRowScopeRefusedError', + }); + expect((ql.registry.getObject('sys_user') as any).defaultRowScope).toEqual(declared); + expect(ql.registry.getObjectContributors('sys_user').map((c: any) => c.packageId)).not.toContain('app.loosener'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5edd43cbf57..b1520a40846 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1958,6 +1958,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../../objectql + '@objectstack/plugin-audit': + specifier: workspace:* + version: link:../plugin-audit '@objectstack/plugin-sharing': specifier: workspace:* version: link:../plugin-sharing