From ca702e9cf13036787e09f4246bc737739d80aa40 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:23:25 +0000 Subject: [PATCH 01/10] feat(platform-objects): the identity objects declare their default row scope The 23 identity objects carry the row policy plugin-security's shipped permission sets spread for them today as ObjectSchema.defaultRowScope: each literal moves verbatim, and sys_user's two (self, organization members) become one select policy joined with ||. sys_audit_log lives in plugin-audit and declares there. No reader yet: the sets keep their spreads, so no served row moves. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RJVtc4cmB5EEgMNC6Si9db --- .../src/identity/sys-account.object.ts | 14 +++++++++++ .../src/identity/sys-api-key.object.ts | 14 +++++++++++ .../src/identity/sys-device-code.object.ts | 14 +++++++++++ .../src/identity/sys-jwks.object.ts | 14 +++++++++++ .../identity/sys-oauth-access-token.object.ts | 14 +++++++++++ .../identity/sys-oauth-application.object.ts | 14 +++++++++++ .../src/identity/sys-oauth-consent.object.ts | 14 +++++++++++ .../sys-oauth-refresh-token.object.ts | 14 +++++++++++ .../src/identity/sys-organization.object.ts | 14 +++++++++++ .../sys-scim-connection-binding.object.ts | 14 +++++++++++ .../identity/sys-scim-group-member.object.ts | 14 +++++++++++ .../src/identity/sys-scim-group.object.ts | 14 +++++++++++ .../sys-scim-identity-tombstone.object.ts | 14 +++++++++++ .../sys-scim-projection-grant.object.ts | 14 +++++++++++ .../src/identity/sys-scim-subject.object.ts | 14 +++++++++++ .../src/identity/sys-scim-user.object.ts | 14 +++++++++++ .../src/identity/sys-session.object.ts | 14 +++++++++++ .../src/identity/sys-team-member.object.ts | 14 +++++++++++ .../src/identity/sys-two-factor.object.ts | 14 +++++++++++ .../identity/sys-user-preference.object.ts | 15 ++++++++++++ .../src/identity/sys-user.object.ts | 23 +++++++++++++++++++ .../src/identity/sys-verification.object.ts | 14 +++++++++++ .../src/objects/sys-audit-log.object.ts | 14 +++++++++++ 23 files changed, 332 insertions(+) diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 83c54a3109e..25982c89130 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..168918df3a5 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..96c7a5c6358 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..4e4bab2ea52 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..5f313c0b4f0 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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 0882680ddb4..f029385b033 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..99af0c10b14 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..3b83aaf7e66 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..7c6e5c8742c 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..696daddacb2 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..8a780e1924f 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..4ce0b34255b 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..fa8636dc3c2 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..d014819acf2 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..175a620e8c2 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..b1fc398508d 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..d6ec5324471 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..4423dd81f3a 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..8507662ac26 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..b4866f76eb6 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 is to apply inside that wall: Layer 1 + // silence is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..217e9b37b8e 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -724,6 +724,29 @@ 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 is to mean 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 the core authorization + // resolver always does (`[]` with no organization); unresolved, the whole + // predicate fails closed, own row included. Not read yet: the sets keep + // carrying both literals, and the security plugin's collection is to apply this + // key when no held set carries a policy for the object. 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..cd88078ab27 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 is to mean this scope, not no filter. Not read yet: the sets keep + // carrying the same literal, and the security plugin's collection is to apply + // this key when no held set carries a policy for the object. 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..34c637d7495 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 is to mean this scope, not no filter. Not read yet: the sets + // keep carrying the same literal, and the security plugin's collection is to + // apply this key when no held set carries a policy for the object. 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({ From 9b7e38b34aaff83e383d209c1cc2a33ca85afaa1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:25:54 +0000 Subject: [PATCH 02/10] feat(objectql): the registry refuses an extend contribution that carries defaultRowScope An object's default row scope is the owner's declaration alone; an extend contributor that sets or loosens it is refused at registerObject, before the contributor list opens, as INVALID_METADATA 422 named ExtensionDefaultRowScopeRefusedError. A contribution that leaves the key alone registers as before. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RJVtc4cmB5EEgMNC6Si9db --- ...xtension-default-row-scope-refused.test.ts | 157 ++++++++++++++++++ packages/objectql/src/registry.ts | 68 ++++++++ 2 files changed, 225 insertions(+) create mode 100644 packages/objectql/src/registry-extension-default-row-scope-refused.test.ts 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 973bbb2f253..f0e82907d3d 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1286,6 +1286,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. @@ -3067,6 +3126,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 From 1bf6312ed94a35e0b50cd347442b65d19ccad93d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 03:30:42 +0000 Subject: [PATCH 03/10] test(plugin-security): pin the identity objects' declared default row scope against the shipped literals Enumerates the 23 declarers against defaultPermissionSets (same name, operation and predicate per object; platform-global objects declare none), pins sys_user's merged predicate to the union of its two literals' rows, and records the measured divergence: with org_user_ids unresolved the merged policy fails closed whole. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01RJVtc4cmB5EEgMNC6Si9db --- packages/plugins/plugin-security/package.json | 1 + .../default-row-scope-declarations.test.ts | 229 ++++++++++++++++++ .../plugins/plugin-security/vitest.config.ts | 8 + pnpm-lock.yaml | 3 + 4 files changed, 241 insertions(+) create mode 100644 packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index a73cb353523..611bf863e03 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/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..ec8cbeee380 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts @@ -0,0 +1,229 @@ +// 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. + * + * Three 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. + * + * No reader of the key is wired yet, and the shipped sets keep their spreads, + * so nothing here moves a served row. + */ + +import { describe, it, expect } from 'vitest'; +import * as PlatformObjects from '@objectstack/platform-objects'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { defaultPermissionSets } from './default-permission-sets.js'; +import { RLSCompiler } from '../rls-compiler.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']); + }); +}); 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/pnpm-lock.yaml b/pnpm-lock.yaml index 9049ea5cebe..627da34f83c 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 From 48b1eb06a5833d1cc8266681861f4ee097da83a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:30:59 +0000 Subject: [PATCH 04/10] test(plugin-security): two row-scope pins read the identity objects' own declarations object-default-row-scope.test.ts: the undeclared shape now drops the key the identity objects declare, so its CONTROL still measures no default. scim-projection-row-scope.test.ts: the MCP agent persona carries the delegating human's userId, as core's assembleExecutionContext sets it for an OAuth agent, so current_user resolves to the delegator on the ceiling's leg, where each SCIM table's declared default now applies. Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- .../plugin-security/src/object-default-row-scope.test.ts | 4 +++- .../plugin-security/src/scim-projection-row-scope.test.ts | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/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; From 371636092e391a4e6c14cc4050c2bac867a77bfd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:55:20 +0000 Subject: [PATCH 05/10] test: pin sys_user's one default on every user-context producer, and the baseline unchanged on the real declarations - service-automation: a booted stack with no baseline and one authored '*' read set reads sys_user under its declared default on a request, a runAs:'user' flow run and a runAs:'user' hook fired from a hand-built system context: its own row plus the acting organisation's members, and exactly the rows the two shipped literals admit on that same context. On the same composition, plugin-auth's sys_sso_provider extension (the one shipped extend of a platform object) still registers, and an extend that sets defaultRowScope is refused by name. - plugin-security: with the shipped baseline, every persona (member, viewer, organization admin, a user in no organisation, the platform admin, and an MCP agent for each of three delegators) reads all 23 declarers exactly as without the declarations; the census names where a default is now collected: on the agent ceilings' legs only. - The declarations' comments now say the collection applies the key. Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- .../src/identity/sys-account.object.ts | 16 +- .../src/identity/sys-api-key.object.ts | 16 +- .../src/identity/sys-device-code.object.ts | 16 +- .../src/identity/sys-jwks.object.ts | 14 +- .../identity/sys-oauth-access-token.object.ts | 16 +- .../identity/sys-oauth-application.object.ts | 16 +- .../src/identity/sys-oauth-consent.object.ts | 16 +- .../sys-oauth-refresh-token.object.ts | 16 +- .../src/identity/sys-organization.object.ts | 16 +- .../sys-scim-connection-binding.object.ts | 14 +- .../identity/sys-scim-group-member.object.ts | 14 +- .../src/identity/sys-scim-group.object.ts | 14 +- .../sys-scim-identity-tombstone.object.ts | 14 +- .../sys-scim-projection-grant.object.ts | 14 +- .../src/identity/sys-scim-subject.object.ts | 14 +- .../src/identity/sys-scim-user.object.ts | 14 +- .../src/identity/sys-session.object.ts | 16 +- .../src/identity/sys-team-member.object.ts | 16 +- .../src/identity/sys-two-factor.object.ts | 16 +- .../identity/sys-user-preference.object.ts | 18 +- .../src/identity/sys-user.object.ts | 32 +- .../src/identity/sys-verification.object.ts | 14 +- .../src/objects/sys-audit-log.object.ts | 16 +- .../default-row-scope-declarations.test.ts | 273 ++++++++++++++- ...user-default-row-scope.integration.test.ts | 329 ++++++++++++++++++ 25 files changed, 781 insertions(+), 189 deletions(-) create mode 100644 packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index 25982c89130..db488c6af2f 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -114,14 +114,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 168918df3a5..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,14 +227,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 96c7a5c6358..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,14 +51,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', diff --git a/packages/platform-objects/src/identity/sys-jwks.object.ts b/packages/platform-objects/src/identity/sys-jwks.object.ts index 4e4bab2ea52..faed80e07da 100644 --- a/packages/platform-objects/src/identity/sys-jwks.object.ts +++ b/packages/platform-objects/src/identity/sys-jwks.object.ts @@ -39,13 +39,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 5f313c0b4f0..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,14 +38,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 7dcb092ec81..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,14 +284,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 99af0c10b14..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,14 +34,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 3b83aaf7e66..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,14 +37,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', diff --git a/packages/platform-objects/src/identity/sys-organization.object.ts b/packages/platform-objects/src/identity/sys-organization.object.ts index 7c6e5c8742c..6ccea718d4b 100644 --- a/packages/platform-objects/src/identity/sys-organization.object.ts +++ b/packages/platform-objects/src/identity/sys-organization.object.ts @@ -299,14 +299,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 696daddacb2..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,13 +51,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 8a780e1924f..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,13 +57,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 4ce0b34255b..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,13 +50,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 fa8636dc3c2..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,13 +46,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 d014819acf2..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,13 +58,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 175a620e8c2..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,13 +53,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 b1fc398508d..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,13 +51,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', diff --git a/packages/platform-objects/src/identity/sys-session.object.ts b/packages/platform-objects/src/identity/sys-session.object.ts index d6ec5324471..d91da28e014 100644 --- a/packages/platform-objects/src/identity/sys-session.object.ts +++ b/packages/platform-objects/src/identity/sys-session.object.ts @@ -150,14 +150,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 4423dd81f3a..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,14 +95,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 8507662ac26..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,14 +140,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 b4866f76eb6..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,15 +70,15 @@ 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 is to apply inside that wall: Layer 1 - // silence is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', diff --git a/packages/platform-objects/src/identity/sys-user.object.ts b/packages/platform-objects/src/identity/sys-user.object.ts index 217e9b37b8e..8236bca5fd5 100644 --- a/packages/platform-objects/src/identity/sys-user.object.ts +++ b/packages/platform-objects/src/identity/sys-user.object.ts @@ -726,21 +726,23 @@ 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 is to mean 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 the core authorization - // resolver always does (`[]` with no organization); unresolved, the whole - // predicate fails closed, own row included. Not read yet: the sets keep - // carrying both literals, and the security plugin's collection is to apply this - // key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // `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', diff --git a/packages/platform-objects/src/identity/sys-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index cd88078ab27..488907889d2 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -53,13 +53,13 @@ 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 is to mean this scope, not no filter. Not read yet: the sets keep - // carrying the same literal, and the security plugin's collection is to apply - // this key when no held set carries a policy for the object. An `extend` + // [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', 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 34c637d7495..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,14 +143,14 @@ 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 is to mean this scope, not no filter. Not read yet: the sets - // keep carrying the same literal, and the security plugin's collection is to - // apply this key when no held set carries a policy for the object. An `extend` - // contribution cannot set or loosen it (refused at registration). + // [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', 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 index ec8cbeee380..38c0e3c7d92 100644 --- 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 @@ -7,7 +7,7 @@ * this package's shipped permission sets carry for that object today — read * here off `defaultPermissionSets`, never retyped. * - * Three pins: + * 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 @@ -19,17 +19,28 @@ * 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. - * - * No reader of the key is wired yet, and the shipped sets keep their spreads, - * so nothing here moves a served row. + * 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, and + * the shipped sets keep their spreads: a deployment with the shipped + * baseline serves every persona the rows it served before the + * declarations, and the census names where a default is now collected. */ -import { describe, it, expect } from 'vitest'; +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'; type Policy = { name: string; @@ -227,3 +238,253 @@ describe('sys_user\'s merged predicate admits the union of its two literals\' ro 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. Each persona reads every declarer, and is + * explained for every operation, identically in both; the census then names + * every persona, object and operation where the declared default is what the + * collection now applies. + */ + +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 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); + 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 two administrators' grants. + 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: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; + const isAgent = (persona: string) => BASELINE_PERSONAS[persona].principalKind === 'agent'; + /** What explain decided, without its prose and composed filter: `allowed` and each layer's verdict. */ + const decided = (e: { allowed: boolean; layers: Array<{ layer: string; verdict: string }> }) => + JSON.stringify({ allowed: e.allowed, layers: e.layers.map((l) => `${l.layer}:${l.verdict}`) }); + + 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 = await before.security.explain({ object, operation }, { ...context }); + const e1 = await after.security.explain({ object, operation }, { ...context }); + // A baseline holder's explain is byte-for-byte. A delegated agent's + // decision is the same, but its ceiling's leg now collects the object + // default, so the rls layer names it and the composed filter carries + // that leg's conjunct (the delegator's own scope, `userId` being the + // delegator's) beside the delegator's. + const same = isAgent(persona) ? decided(e1) === decided(e0) : JSON.stringify(e1) === JSON.stringify(e0); + if (!same) 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['mcp agent for the member'])).toEqual(['sys_session:u_x']); + expect(await after.served('sys_session', BASELINE_PERSONAS.platform_admin)).toHaveLength(BASELINE_USERS.length); + }); + + it('census: the declared default is collected for no baseline holder, and on a delegated agent ceiling\'s leg', async () => { + const { plugin } = await bootBaseline(true); + const objects = await registeredObjects(); + const applied: string[] = []; + 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; + const sets = await plugin.resolvePermissionSetsForContext({ ...context }); + for (const object of DECLARERS) { + for (const operation of COLLECTED) { + const collected = plugin.collectRLSPolicies(sets, object, operation, (context.positions ?? []) as string[]); + if (collected.some((p: object) => isObjectDefaultRowScopePolicy(p))) applied.push(`${persona} ${object} ${operation}`); + } + } + } + // Every human holds `member_default` beside their own sets, and it carries + // a policy for every declarer on every operation (its literals, and its + // `'*'` write floor), so no baseline holder collects a default. + expect(applied.filter((line) => !line.startsWith('mcp agent'))).toEqual([]); + // The agent ceilings carry no row policy (ADR-0090 D10): an agent's own + // leg collects each declarer's default, on exactly the operations the + // declaration names. + const agents = Object.keys(BASELINE_PERSONAS).filter(isAgent); + const expected = agents.flatMap((persona) => + DECLARERS.flatMap((object) => { + const scope = objects.get(object)!.defaultRowScope!; + return COLLECTED.filter((op) => scope.operation === 'all' || scope.operation === op).map((op) => `${persona} ${object} ${op}`); + }), + ); + expect(applied.sort()).toEqual(expected.sort()); + }); +}); 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..e07703fb133 --- /dev/null +++ b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts @@ -0,0 +1,329 @@ +// 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 } as any))) 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']); + const security = kernel.getService('security'); + const decision = await security.explain({ object: 'sys_user', operation: 'read' }, await requestContext('usr_me', orgA)); + const rls = (decision.layers as Array<{ layer: string; verdict: string; detail: string }>).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'); + }); +}); From 41cd5e416a4d64a3fe406b87f4de7d417229897f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:00:56 +0000 Subject: [PATCH 06/10] chore(changeset): the identity objects' declared default row scope, and the registry's extend refusal Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- .../24341-extend-default-row-scope-refused.md | 26 ++++++++++++++++++ ...4341-identity-objects-default-row-scope.md | 27 +++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .changeset/24341-extend-default-row-scope-refused.md create mode 100644 .changeset/24341-identity-objects-default-row-scope.md 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..325aad37f11 --- /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 (`.changeset/pre.json` is open in `next` pre mode). + +- 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..fc6e3e03858 --- /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 (`.changeset/pre.json` is open in `next` pre mode). + +- 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 as before. Its ceiling set carries no row policy, so the declared default now applies on the agent's own side of the intersection, evaluated as the delegating user (the agent's `userId` is the delegator's). The explain output for such an agent now names the object default on that side. +- The platform administrator's superuser bypass is decided before any row policy is collected. +- No export, option or stored shape moves. + + From 0367cb185512498303a4c16bc4681e57bf73f84c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:57:22 +0000 Subject: [PATCH 07/10] test(service-automation): type the sys_user default pin's query options and security service lookup Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- .../src/sys-user-default-row-scope.integration.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 index e07703fb133..9b54bb93f69 100644 --- 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 @@ -214,7 +214,7 @@ describe("sys_user's one `||` default keeps the reader's own row on every user-c 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 } as any))) out.add(id); + for (const id of ids(await ql.find('sys_user', { where, fields: ['id'], context: SYS }))) out.add(id); } return [...out].sort(); } @@ -248,9 +248,10 @@ describe("sys_user's one `||` default keeps the reader's own row on every user-c 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']); - const security = kernel.getService('security'); + 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 as Array<{ layer: string; verdict: string; detail: string }>).find((l) => l.layer === 'rls'); + 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'); From efaf66a5138a35245ce0dc2ccb7b5eea0e02d794 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:28:08 +0000 Subject: [PATCH 08/10] chore(changeset): drop the pre-mode aside now that main has exited pre mode Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- .changeset/24341-extend-default-row-scope-refused.md | 2 +- .changeset/24341-identity-objects-default-row-scope.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/24341-extend-default-row-scope-refused.md b/.changeset/24341-extend-default-row-scope-refused.md index 325aad37f11..5f79d1fb710 100644 --- a/.changeset/24341-extend-default-row-scope-refused.md +++ b/.changeset/24341-extend-default-row-scope-refused.md @@ -10,7 +10,7 @@ An object's default row scope is declared by the object's owner alone. `SchemaRe 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 (`.changeset/pre.json` is open in `next` pre mode). +**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`). diff --git a/.changeset/24341-identity-objects-default-row-scope.md b/.changeset/24341-identity-objects-default-row-scope.md index fc6e3e03858..e8ff314d9e0 100644 --- a/.changeset/24341-identity-objects-default-row-scope.md +++ b/.changeset/24341-identity-objects-default-row-scope.md @@ -11,7 +11,7 @@ Twenty-three platform objects now declare `defaultRowScope`, each with the row p 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 (`.changeset/pre.json` is open in `next` pre mode). +**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). From 09830c3134db3243a41469d659997ef65809913a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:45:46 +0000 Subject: [PATCH 09/10] fix(plugin-security): a delegated agent's own leg does not collect an object's declared default row scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ruling of record 6278784335 (letter B): the collection adds an object's defaultRowScope on an empty collection except on a delegated principal's own leg, the ceiling whose row bound is the delegator's leg (ADR-0090 D10). Without it, a platform administrator's agent read only the administrator's own rows on the identity objects, where it read every row. collectRLSPolicies takes the context it collects for as a private parameter, threaded from its six callers; the test is the read door's own (onBehalfOf.userId), in an unexported module function. The ③.11 pin now asserts each delegated agent's served ids on both trees (member, viewer, organisation admin and platform administrator), compares every persona's read explain byte-for-byte, and its census reads the collection per context, with an undelegated control. Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- ...341-delegated-ceiling-no-object-default.md | 19 +++ ...4341-identity-objects-default-row-scope.md | 2 +- .../default-row-scope-declarations.test.ts | 119 +++++++++++------- .../plugin-security/src/security-plugin.ts | 39 +++++- 4 files changed, 129 insertions(+), 50 deletions(-) create mode 100644 .changeset/24341-delegated-ceiling-no-object-default.md 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-identity-objects-default-row-scope.md b/.changeset/24341-identity-objects-default-row-scope.md index e8ff314d9e0..6a1bd58028e 100644 --- a/.changeset/24341-identity-objects-default-row-scope.md +++ b/.changeset/24341-identity-objects-default-row-scope.md @@ -20,7 +20,7 @@ The security plugin's row-policy collection applies an object's declared default **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 as before. Its ceiling set carries no row policy, so the declared default now applies on the agent's own side of the intersection, evaluated as the delegating user (the agent's `userId` is the delegator's). The explain output for such an agent now names the object default on that side. +- 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/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts index 38c0e3c7d92..855af5ddd84 100644 --- 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 @@ -23,10 +23,11 @@ * 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, and - * the shipped sets keep their spreads: a deployment with the shipped - * baseline serves every persona the rows it served before the - * declarations, and the census names where a default is now collected. + * 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'; @@ -41,6 +42,7 @@ 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; @@ -249,10 +251,13 @@ describe('sys_user\'s merged predicate admits the union of its two literals\' ro * 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. Each persona reads every declarer, and is - * explained for every operation, identically in both; the census then names - * every persona, object and operation where the declared default is what the - * collection now applies. + * 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; @@ -290,6 +295,11 @@ const BASELINE_PERSONAS: Record = { 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'], @@ -384,17 +394,22 @@ async function bootBaseline(declared: boolean) { 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 two administrators' grants. + // 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 }, ]); @@ -417,9 +432,6 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t const EXPLAINED = ['read'] as const; const COLLECTED = ['select', 'insert', 'update', 'delete'] as const; const isAgent = (persona: string) => BASELINE_PERSONAS[persona].principalKind === 'agent'; - /** What explain decided, without its prose and composed filter: `allowed` and each layer's verdict. */ - const decided = (e: { allowed: boolean; layers: Array<{ layer: string; verdict: string }> }) => - JSON.stringify({ allowed: e.allowed, layers: e.layers.map((l) => `${l.layer}:${l.verdict}`) }); 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); @@ -433,15 +445,9 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t 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 = await before.security.explain({ object, operation }, { ...context }); - const e1 = await after.security.explain({ object, operation }, { ...context }); - // A baseline holder's explain is byte-for-byte. A delegated agent's - // decision is the same, but its ceiling's leg now collects the object - // default, so the rls layer names it and the composed filter carries - // that leg's conjunct (the delegator's own scope, `userId` being the - // delegator's) beside the delegator's. - const same = isAgent(persona) ? decided(e1) === decided(e0) : JSON.stringify(e1) === JSON.stringify(e0); - if (!same) explainMoved.push(`${label} (${operation})`); + 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})`); } } } @@ -452,39 +458,64 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t // 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['mcp agent for the member'])).toEqual(['sys_session:u_x']); expect(await after.served('sys_session', BASELINE_PERSONAS.platform_admin)).toHaveLength(BASELINE_USERS.length); }); - it('census: the declared default is collected for no baseline holder, and on a delegated agent ceiling\'s leg', async () => { + 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 applied: string[] = []; - 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; + 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[]); - if (collected.some((p: object) => isObjectDefaultRowScopePolicy(p))) applied.push(`${persona} ${object} ${operation}`); + 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([]); } - // Every human holds `member_default` beside their own sets, and it carries - // a policy for every declarer on every operation (its literals, and its - // `'*'` write floor), so no baseline holder collects a default. - expect(applied.filter((line) => !line.startsWith('mcp agent'))).toEqual([]); - // The agent ceilings carry no row policy (ADR-0090 D10): an agent's own - // leg collects each declarer's default, on exactly the operations the - // declaration names. - const agents = Object.keys(BASELINE_PERSONAS).filter(isAgent); - const expected = agents.flatMap((persona) => - DECLARERS.flatMap((object) => { - const scope = objects.get(object)!.defaultRowScope!; - return COLLECTED.filter((op) => scope.operation === 'all' || scope.operation === op).map((op) => `${persona} ${object} ${op}`); - }), - ); - expect(applied.sort()).toEqual(expected.sort()); + // 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/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 From f1049494f97c57495388bab9282c65c3e6aa0581 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:57:39 +0000 Subject: [PATCH 10/10] =?UTF-8?q?test(plugin-security):=20drop=20the=20?= =?UTF-8?q?=E2=91=A2.11=20pin's=20unused=20agent=20predicate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01FatVyDAFEST48jZDQcbheU Co-authored-by: Claude --- .../src/objects/default-row-scope-declarations.test.ts | 1 - 1 file changed, 1 deletion(-) 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 index 855af5ddd84..fcf26d47df8 100644 --- 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 @@ -431,7 +431,6 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t // 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; - const isAgent = (persona: string) => BASELINE_PERSONAS[persona].principalKind === 'agent'; 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);