Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/24341-delegated-ceiling-no-object-default.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 26 additions & 0 deletions .changeset/24341-extend-default-row-scope-refused.md
Original file line number Diff line number Diff line change
@@ -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`.

<!-- adr-0087: not-required (no-migration-prescription) a refusal at the registry's extend door of a key the extension merge never carried: no authorable key, spelling, export or stored shape moves, and the manifest path already refuses the key at authoring. The repair is removing the key from a programmatic extend call, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this door (not already-registered); and the change is a door verdict, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
27 changes: 27 additions & 0 deletions .changeset/24341-identity-objects-default-row-scope.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) declarations of a row scope on platform objects, each the policy the shipped permission sets already carry: no authorable key, spelling, export or stored shape moves, and no stored row is read, rewritten or converted. What narrows is the rows a principal with no baseline and no row policy of its own reads, and the repair is a deployer's choice of a `rowLevelSecurity` policy, which no ledger entry can derive. The other categories are closed on facts: both packages publish (not unpublished); no ADR-0087 id covers row scopes (not already-registered); and the change is a runtime row scope, not an interface, a type or an export (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
Original file line number Diff line number Diff line change
@@ -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);
});
});
68 changes: 68 additions & 0 deletions packages/objectql/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading