diff --git a/.changeset/24341-identity-objects-default-row-scope.md b/.changeset/24341-identity-objects-default-row-scope.md index 6a1bd58028e..533ca352235 100644 --- a/.changeset/24341-identity-objects-default-row-scope.md +++ b/.changeset/24341-identity-objects-default-row-scope.md @@ -7,9 +7,9 @@ feat(platform-objects)!: the platform identity objects declare their default row 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. +Twenty-three platform objects now declare `defaultRowScope`, each with the row policy the shipped permission sets of `@objectstack/plugin-security` carried 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. +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. On each object and operation the shipped sets either still carry the same policy or, where they no longer carry a copy (the `@objectstack/plugin-security` entry names the thirteen), collect the object's identical default, 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. @@ -19,7 +19,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. +- 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 on each object and operation the baseline either carries a policy or collects the object's identical default. Measured on a booted composition with a member, a viewer, an organisation admin, a user in no organisation and the platform administrator, before and after the declarations. - An MCP agent acting for a user reads the same rows, and gets the same explain output, as before. Its ceiling set carries no row policy, and the security plugin does not collect an object's declared default on a delegated principal's own side of the intersection (see the `@objectstack/plugin-security` entry), so the agent's rows stay bounded by its delegator's side alone. Measured on a booted composition, before and after the declarations, for the agents of a member, a viewer, an organisation admin and the platform administrator; the platform administrator's agent still reads every row. - The platform administrator's superuser bypass is decided before any row policy is collected. - No export, option or stored shape moves. diff --git a/.changeset/24484-sets-stop-copying-identity-defaults.md b/.changeset/24484-sets-stop-copying-identity-defaults.md new file mode 100644 index 00000000000..a13f9b4fd3c --- /dev/null +++ b/.changeset/24484-sets-stop-copying-identity-defaults.md @@ -0,0 +1,18 @@ +--- +'@objectstack/plugin-security': patch +--- + +fix(plugin-security): the shipped permission sets stop copying the row scope thirteen identity objects declare as their own default + +Clause-②: no + +`organization_admin` (and so `organization_admin_no_bypass`), `member_default` and `viewer_readonly` no longer carry their copies of the row policy that thirteen identity objects declare as their `defaultRowScope`. The thirteen are the seven SCIM projection tables (`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`, and the `select` self scope of `sys_account`, `sys_team_member`, `sys_oauth_access_token` and `sys_oauth_refresh_token`. Each copy was byte-identical to its object's declaration. The row-policy collection applies an object's declared default wherever the caller's held sets carry no policy for that object and operation, so every holder of the shipped sets reads exactly the rows it read before. That holds with the platform baseline and without it (`fallbackPermissionSet: null`), under the walled and the `single` posture, and for an MCP agent acting for a holder. + +Explain now names the object's default (`via: 'object_default'`) where it named the set's policy on those thirteen objects. + +**Kept in the sets.** + +- `sys_user`'s two policies, and every `all`-spelled self scope (`sys_session`, `sys_two_factor`, `sys_user_preference`, `sys_api_key`, `sys_device_code`, `sys_oauth_consent`, `sys_oauth_application`, `sys_organization`). A member's `update` and `delete` collection holds the `'*'` ownership floor, so the object's default is never collected there, and these copies are what scope a member's writes to their own rows. +- `sys_audit_log_org`. Where no organization wall is enforced, the platform's own tenant policies are stripped by provenance, and that provenance is read off these sets. The copy is what makes the ledger's identical default the platform's policy, so `single` keeps serving the ledger unscoped. + +**If an application set authors a row policy for one of these thirteen objects**, that policy is used as authored and the object's default is not added beside it. Before, a holder who also held a shipped set had that set's copy OR-ed in, so an authored scope that left out the holder's own rows was widened back to them. Now the authored policy alone decides on that object and operation. Measured: no package or example in this repository authors such a policy. diff --git a/packages/platform-objects/src/identity/sys-account.object.ts b/packages/platform-objects/src/identity/sys-account.object.ts index db488c6af2f..587b431d219 100644 --- a/packages/platform-objects/src/identity/sys-account.object.ts +++ b/packages/platform-objects/src/identity/sys-account.object.ts @@ -115,13 +115,13 @@ export const SysAccount = ObjectSchema.create({ }, // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy - // plugin-security's shipped permission sets spread for it today (the `_self` - // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert - // on this table (no organization column), so Layer 1 silence means this scope, not no - // filter. The security plugin's row-policy collection applies it when no set the - // caller holds carries a row policy for the object and operation; the shipped sets - // still carry it, so their holders read as before. An `extend` contribution cannot set - // or loosen it (refused at registration). + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). + // 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 — + // so to every shipped set's holder, whose reads and explain name this scope. 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-jwks.object.ts b/packages/platform-objects/src/identity/sys-jwks.object.ts index faed80e07da..8ee3bf73074 100644 --- a/packages/platform-objects/src/identity/sys-jwks.object.ts +++ b/packages/platform-objects/src/identity/sys-jwks.object.ts @@ -40,13 +40,13 @@ export const SysJwks = ObjectSchema.create({ 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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_jwks_none', operation: 'select', 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 c53254e88c6..e5ed48767a8 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 @@ -39,13 +39,13 @@ export const SysOauthAccessToken = ObjectSchema.create({ highlightFields: ['client_id', 'user_id', 'expires_at'], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy - // plugin-security's shipped permission sets spread for it today (the `_self` - // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert - // on this table (no organization column), so Layer 1 silence means this scope, not no - // filter. The security plugin's row-policy collection applies it when no set the - // caller holds carries a row policy for the object and operation; the shipped sets - // still carry it, so their holders read as before. An `extend` contribution cannot set - // or loosen it (refused at registration). + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). + // 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 — + // so to every shipped set's holder, whose reads and explain name this scope. 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-refresh-token.object.ts b/packages/platform-objects/src/identity/sys-oauth-refresh-token.object.ts index da63bf5fea9..cc6fed93c35 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 @@ -38,13 +38,13 @@ export const SysOauthRefreshToken = ObjectSchema.create({ highlightFields: ['client_id', 'user_id', 'expires_at'], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy - // plugin-security's shipped permission sets spread for it today (the `_self` - // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert - // on this table (no organization column), so Layer 1 silence means this scope, not no - // filter. The security plugin's row-policy collection applies it when no set the - // caller holds carries a row policy for the object and operation; the shipped sets - // still carry it, so their holders read as before. An `extend` contribution cannot set - // or loosen it (refused at registration). + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). + // 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 — + // so to every shipped set's holder, whose reads and explain name this scope. 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-scim-connection-binding.object.ts b/packages/platform-objects/src/identity/sys-scim-connection-binding.object.ts index 3e1a7602878..510b3d9ed7a 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 @@ -52,13 +52,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_connection_binding_none', operation: 'select', 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 1e21ef82ad6..d27e840feb0 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 @@ -58,13 +58,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_group_member_none', operation: 'select', 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 0d20a8f1758..4842d5a2605 100644 --- a/packages/platform-objects/src/identity/sys-scim-group.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-group.object.ts @@ -51,13 +51,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_group_none', operation: 'select', 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 072d22280e6..3689408974c 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 @@ -47,13 +47,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_identity_tombstone_self', operation: 'select', 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 b0a20e05365..71d597447d0 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 @@ -59,13 +59,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_projection_grant_self', operation: 'select', 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 e2645abe45e..30e8e4deaa9 100644 --- a/packages/platform-objects/src/identity/sys-scim-subject.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-subject.object.ts @@ -54,13 +54,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_subject_self', operation: 'select', 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 7689e222e15..6e657e220d4 100644 --- a/packages/platform-objects/src/identity/sys-scim-user.object.ts +++ b/packages/platform-objects/src/identity/sys-scim-user.object.ts @@ -52,13 +52,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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_scim_user_self', operation: 'select', 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 c52b8b7d73a..ca5ce983f67 100644 --- a/packages/platform-objects/src/identity/sys-team-member.object.ts +++ b/packages/platform-objects/src/identity/sys-team-member.object.ts @@ -96,13 +96,13 @@ export const SysTeamMember = ObjectSchema.create({ ], // [ADR-0095 D1, 2026-10-18 note] This object's default row scope: the one policy - // plugin-security's shipped permission sets spread for it today (the `_self` - // carve-outs in `default-permission-sets.ts`), moved here verbatim. Layer 0 is inert - // on this table (no organization column), so Layer 1 silence means this scope, not no - // filter. The security plugin's row-policy collection applies it when no set the - // caller holds carries a row policy for the object and operation; the shipped sets - // still carry it, so their holders read as before. An `extend` contribution cannot set - // or loosen it (refused at registration). + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). + // 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 — + // so to every shipped set's holder, whose reads and explain name this scope. 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-verification.object.ts b/packages/platform-objects/src/identity/sys-verification.object.ts index 488907889d2..2c7ca3fb2f8 100644 --- a/packages/platform-objects/src/identity/sys-verification.object.ts +++ b/packages/platform-objects/src/identity/sys-verification.object.ts @@ -54,13 +54,13 @@ export const SysVerification = ObjectSchema.create({ 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. + // plugin-security's shipped permission sets used to spread for it, moved here + // verbatim; the sets no longer carry a copy (#24164 ruling E′ item 3's follow-up). // 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). + // when no set the caller holds carries a row policy for the object and operation — + // so to every shipped set's holder, whose reads and explain name this scope. An + // `extend` contribution cannot set or loosen it (refused at registration). defaultRowScope: { name: 'sys_verification_none', operation: 'select', 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 db89d1ddaaa..77e787bf114 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 @@ -24,7 +24,12 @@ * another operation or outside the caller's positions does not. * 3. The platform administrator is unchanged. * 4. Explain names the object default. - * 5. A deployment with the baseline is byte-for-byte unchanged. + * 5. A deployment with the baseline is byte-for-byte unchanged on the objects + * whose shipped sets still carry their literal (`sys_user`, `sys_session`); + * on `sys_scim_user`, whose copy the shipped sets no longer carry (ruling + * E′ item 3's follow-up), the declaration is the scope the baseline reads + * under (`objects/shipped-sets-identity-default-row-scope.test.ts` pins the + * shipped tree's rows on every such object). * 6. A default whose `using` does not lower is refused the way an authored * policy's is: dropped with the compiler's WARN, failing closed to zero * rows, on the same object with the same predicate. @@ -399,11 +404,11 @@ describe('[#24164 E′] a deployment with the baseline is byte-for-byte unchange platform_admin: PLATFORM_ADMIN, }; - it('every shipped persona reads and is explained exactly as without the declarations', async () => { + it('every shipped persona reads and is explained exactly as without the declarations, where the sets carry the literal', async () => { const now = await boot({ baseline: true, declared: true }); const before = await boot({ baseline: true, declared: false }); for (const [persona, context] of Object.entries(PERSONAS)) { - for (const object of ['sys_user', 'sys_session', 'sys_scim_user']) { + for (const object of ['sys_user', 'sys_session']) { const label = `${persona} on ${object}`; const served = await before.ids(object, context); expect(await now.ids(object, context), label).toEqual(served); @@ -417,6 +422,21 @@ describe('[#24164 E′] a deployment with the baseline is byte-for-byte unchange expect(await now.ids('sys_user', PERSONAS.member_default)).toEqual(['u_x', 'u_x2']); expect(await now.ids('sys_session', PERSONAS.member_default)).toEqual(['sess:u_x']); }); + + it('on sys_scim_user, whose copy the shipped sets no longer carry, the declaration is the scope every shipped persona below platform admin reads under', async () => { + const now = await boot({ baseline: true, declared: true }); + const before = await boot({ baseline: true, declared: false }); + for (const persona of ['member_default', 'viewer_readonly', 'organization_admin'] as const) { + const context = PERSONAS[persona]; + expect(await now.ids('sys_scim_user', context), persona).toEqual([`scim:${String(context.userId)}`]); + const layer = (await now.security.explain({ object: 'sys_scim_user', operation: 'read' }, { ...context })).layers + .find((l: { layer: string }) => l.layer === 'rls'); + expect(layer?.detail, persona).toContain(`object default row scope '${String(IDENTITY_DEFAULTS.sys_scim_user.name)}'`); + // CONTROL: without the declaration nothing in the baseline scopes it. + expect(await before.ids('sys_scim_user', context), persona).toEqual(USERS.map((u) => `scim:${u}`).sort()); + } + expect(await now.ids('sys_scim_user', PERSONAS.platform_admin)).toEqual(USERS.map((u) => `scim:${u}`).sort()); + }); }); describe('[#24164 E′] a default whose `using` does not lower is refused the way an authored policy\'s is', () => { diff --git a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts index 2a704cc96e0..939e36e4207 100644 --- a/packages/plugins/plugin-security/src/objects/default-permission-sets.ts +++ b/packages/plugins/plugin-security/src/objects/default-permission-sets.ts @@ -34,10 +34,12 @@ import { SysUser } from '@objectstack/platform-objects/identity'; * * ⚠️ "Subject to the rest of the RLS chain" is the load-bearing half, and it is * a BLANKET grant — read this list as objects whose object-level read bit is - * open, each narrowed (or not) by whatever `rowLevelSecurity` its holder set - * declares for it. An object named here with NO `_self` / `_org` policy in - * `member_default` is org-wide readable by every authenticated member. That is - * intended for the staff-directory shapes (`sys_member`, `sys_user` via + * open, each narrowed (or not) by whatever `rowLevelSecurity` its holder's sets + * declare for it or, where they declare none, by the object's own declared + * default row scope (`defaultRowScope`; see "Identity tables scoped by their + * own declaration" below). An object named here with NEITHER is org-wide + * readable by every authenticated member. That is intended for the + * staff-directory shapes (`sys_member`, `sys_user` via * `sys_user_org_members`) and was NOT intended for `sys_invitation` * (maintainer ruling 2026-08-12) — see `sys_invitation_self` below. Do not * "fix" a future instance of this class by editing the blanket: dropping @@ -103,147 +105,60 @@ const denyWritesOnManagedObjects = (): Record [ - { name: 'sys_scim_user_self', object: 'sys_scim_user', operation: 'select', using: 'user_id == current_user.id' }, - { name: 'sys_scim_subject_self', object: 'sys_scim_subject', operation: 'select', using: 'user_id == current_user.id' }, - { - name: 'sys_scim_projection_grant_self', - object: 'sys_scim_projection_grant', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_scim_identity_tombstone_self', - object: 'sys_scim_identity_tombstone', - operation: 'select', - using: 'user_id == current_user.id', - }, - { name: 'sys_scim_group_none', object: 'sys_scim_group', operation: 'select', using: 'id == null' }, - { name: 'sys_scim_group_member_none', object: 'sys_scim_group_member', operation: 'select', using: 'id == null' }, - { - name: 'sys_scim_connection_binding_none', - object: 'sys_scim_connection_binding', - operation: 'select', - using: 'id == null', - }, -]; - -/** - * [#20027] The row scope of the two better-auth tables whose rows ARE - * credentials: `sys_verification` (one-time verification and password-reset - * tokens) and `sys_jwks` (the environment's JWT signing keys). Both declare - * `access: { default: 'private' }`, and the contract this file has always - * stated for them is "DENY for non-admins by design" — see the note above the - * `_self` carve-outs in `member_default`. - * - * Why the declaration alone did not hold: `private` keeps a plain `'*'` - * wildcard off an object (`resolveObjectPermission`), but the blanket above - * gives these objects an EXPLICIT per-object read entry, and an explicit entry - * is not the wildcard. The object-level read therefore resolved `true` for - * every set holding the blanket, and neither object had a row policy. The - * retired wildcard `tenant_isolation` policy was what used to deny their rows; - * ADR-0095 D1 moved the tenant wall to Layer 0, which is inert on an object - * with no tenant column. One more instance of the class this file's header - * describes, closed with the instrument the header names: the per-object row - * scope, ⛔ never an edit of the blanket. - * - * `_none` (`id == null`, i.e. no row), the shape `scimProjectionRowScope` - * uses. There is no `_self` form: neither table names a user - * (`sys_verification` keys on an address, `sys_jwks` on nothing), and no - * principal needs to read even their OWN live token — better-auth reads it. - * - * Who keeps every row, and why this does not reach them: - * - the platform admin (`admin_full_access`): its wildcard carries the - * superuser read bypass, which skips Layer 1, and it names neither object - * explicitly; - * - better-auth itself: it reads through its adapter under system context - * (`withSystemContext`, plugin-auth), which no row policy reaches. - * - * Why a policy, and not `private` taking precedence over explicit entries in - * the evaluator: three OTHER `private` objects on the blanket — - * `sys_device_code`, `sys_oauth_access_token`, `sys_oauth_refresh_token` — are - * deliberately readable by their owner through that same explicit entry plus - * a `_self` policy. An evaluator rule would retire those reads too; this scope - * touches only the two objects the contract names. - * - * `select` (not `all`): every write on these tables is already denied at the - * object layer by the blanket. Spread into EVERY shipped set that spreads the - * blanket and carries row-level security (`organization_admin`, and so its - * derived no-bypass variant, `member_default`, `viewer_readonly`), for the - * reason `scimProjectionRowScope` gives: where no platform baseline is - * composed, a set with no policy for an object leaves it unfiltered. The MCP - * write ceiling holds the blanket too; its bound is the delegating user's own - * sets (ADR-0090 D10), which carry this scope. A fresh array per set. - */ -const privateCredentialRowScope = () => [ - { name: 'sys_verification_none', object: 'sys_verification', operation: 'select', using: 'id == null' }, - { name: 'sys_jwks_none', object: 'sys_jwks', operation: 'select', using: 'id == null' }, -]; /** * [ADR-0131 D7] The organization read scope of the compliance ledger, @@ -272,10 +187,17 @@ const privateCredentialRowScope = () => [ * - **Where it is spread**: every shipped set that carries row-level security — * `organization_admin` (and so its derived no-bypass variant), * `viewer_readonly`, whose wildcard reads the ledger, and `member_default`, - * the baseline every authenticated human resolves, so a ledger read an - * application set grants is scoped as well. A set with no policy for an - * object leaves it unfiltered, and `member_default` is absent where no - * platform baseline is composed (the reason `scimProjectionRowScope` gives). + * the baseline every authenticated human resolves. + * - **Why the sets keep it although `sys_audit_log` declares this same literal + * as its default row scope** (measured by #24164 ruling E′ item 3's + * follow-up, which removed the identity tables' copies): the strip above + * recognises a platform tenant policy by PROVENANCE, and the provenance keys + * are read off THESE sets' policies (`platform-tenant-policies.ts`). Dropped + * from every set, the object's identical default would no longer be the + * platform's: it would survive the wall-less strip as an authored tenant + * policy and narrow the ledger read `single` serves unscoped. Keep at least + * this spelling here until provenance is read off the declaration + * (`shipped-sets-identity-default-row-scope.test.ts` pins the `single` read). * ⚠️ Out of reach: an application set that grants the superuser read bypass * on the ledger (`viewAllRecords` on it or on a wildcard) skips Layer 1, as * every such grant does on an object Layer 0 does not wall. @@ -334,9 +256,10 @@ function identityAdminGroupFields(): string[] { * - `withhold` → `member_default`, `viewer_readonly`: the shipped non-admin * sets that open identity-object read to an org peer * (`sys_user_org_members`). Repeated on `viewer_readonly` rather than - * inherited, for the reason `scimProjectionRowScope` gives: where no - * platform baseline is composed, a set naming nothing leaves the field - * served. + * inherited: where no platform baseline is composed + * (`fallbackPermissionSet: null`) `member_default` is not resolved, and + * field security has no object-declared default to fall back to, so a set + * naming nothing leaves the field served. * - `keep` → `admin_full_access`, `organization_admin` (and so its derived * no-bypass variant). * @@ -532,18 +455,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'all', using: 'user_id == current_user.id', }, - { - name: 'sys_account_self', - object: 'sys_account', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_team_member_self', - object: 'sys_team_member', - operation: 'select', - using: 'user_id == current_user.id', - }, { name: 'sys_two_factor_self', object: 'sys_two_factor', @@ -568,18 +479,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'all', using: 'user_id == current_user.id', }, - { - name: 'sys_oauth_access_token_self', - object: 'sys_oauth_access_token', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_oauth_refresh_token_self', - object: 'sys_oauth_refresh_token', - operation: 'select', - using: 'user_id == current_user.id', - }, { name: 'sys_oauth_consent_self', object: 'sys_oauth_consent', @@ -666,15 +565,14 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'organization_id == current_user.organization_id', }, - // [#20001] The SCIM projection tables: self-only / no row, and deliberately - // no org-admin admission — Layer 0 is inert on them, so an `id != null` - // admission shaped like `sys_invitation_org_admin` would read every - // organization's rows. See `scimProjectionRowScope` above. - ...scimProjectionRowScope(), - // [#20027] The credential tables: no row. The org admin's `viewAllRecords` - // does not reach them — the blanket's explicit entry, not the wildcard, - // is what resolves for them. See `privateCredentialRowScope` above. - ...privateCredentialRowScope(), + // [#24164 E′ item 3] The SCIM projection tables, the credential tables, + // `sys_account`, `sys_team_member` and the two OAuth token tables read + // under their objects' own declared scope: see "Identity tables scoped by + // their own declaration" above. ⛔ No org-admin admission for any of + // them here: a policy in this set REPLACES the declared scope for its + // holders, the org admin's `viewAllRecords` does not reach them (the + // blanket's explicit entry, not the wildcard, resolves for them), and + // Layer 0 is inert on them. // [ADR-0131 D7] The compliance ledger: the rows about the active // organization. See `auditLedgerRowScope` above. ...auditLedgerRowScope(), @@ -909,8 +807,15 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // `admin_full_access`, which has no RLS) should inspect them. // [#20027] ⚠️ Since the wildcard policy retired, nothing above denies // them: the blanket's explicit read entry opens the object, so the - // deny is held by `privateCredentialRowScope` (spread at the end of this - // list and of the two sibling sets). Keep the two together. + // deny is held by each table's own declared default row scope (`_none`; + // see "Identity tables scoped by their own declaration" above), which no + // policy here may replace. `sys_account`, `sys_team_member` and the two + // OAuth token tables read under their declared `_self` scope the same + // way. Of the tables that declare one, the carve-outs that stay below + // are `sys_user`'s (spelled apart from its merged declaration) and the + // `all` scopes, whose write arm the `'*'` ownership floor above would + // otherwise pre-empt: with the floor in the collection, the declared + // default is never collected on `update` / `delete` for an `org_member`. { name: 'sys_organization_self', object: 'sys_organization', @@ -947,7 +852,8 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // pre-resolved by runtime/resolve-execution-context from // `sys_member` for the active organization. Sensitive credential // tables (`sys_account`, `sys_session`, `sys_api_key`, …) keep - // their stricter self-only carve-outs above. + // their stricter self-only scopes (the carve-outs here, or the + // table's own declared default). { name: 'sys_user_org_members', object: 'sys_user', @@ -960,18 +866,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'all', using: 'user_id == current_user.id', }, - { - name: 'sys_account_self', - object: 'sys_account', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_team_member_self', - object: 'sys_team_member', - operation: 'select', - using: 'user_id == current_user.id', - }, { name: 'sys_two_factor_self', object: 'sys_two_factor', @@ -996,18 +890,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'all', using: 'user_id == current_user.id', }, - { - name: 'sys_oauth_access_token_self', - object: 'sys_oauth_access_token', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_oauth_refresh_token_self', - object: 'sys_oauth_refresh_token', - operation: 'select', - using: 'user_id == current_user.id', - }, { name: 'sys_oauth_consent_self', object: 'sys_oauth_consent', @@ -1228,11 +1110,9 @@ const baseDefaultPermissionSets: PermissionSet[] = [ using: 'id != null', positions: ['org_member'], }, - // [#20001] The SCIM projection tables: a member reads the rows about - // themselves and no other — see `scimProjectionRowScope` above. - ...scimProjectionRowScope(), - // [#20027] The credential tables: no row — see `privateCredentialRowScope`. - ...privateCredentialRowScope(), + // [#20001, #20027] The SCIM projection and credential tables read under + // their objects' own declared scope — see "Identity tables scoped by + // their own declaration" above. // [ADR-0131 D7] The compliance ledger: the rows about the active // organization, for a ledger read any set grants — see `auditLedgerRowScope`. ...auditLedgerRowScope(), @@ -1287,18 +1167,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'user_id == current_user.id', }, - { - name: 'sys_account_self', - object: 'sys_account', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_team_member_self', - object: 'sys_team_member', - operation: 'select', - using: 'user_id == current_user.id', - }, { name: 'sys_two_factor_self', object: 'sys_two_factor', @@ -1323,18 +1191,6 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'user_id == current_user.id', }, - { - name: 'sys_oauth_access_token_self', - object: 'sys_oauth_access_token', - operation: 'select', - using: 'user_id == current_user.id', - }, - { - name: 'sys_oauth_refresh_token_self', - object: 'sys_oauth_refresh_token', - operation: 'select', - using: 'user_id == current_user.id', - }, { name: 'sys_oauth_consent_self', object: 'sys_oauth_consent', @@ -1359,15 +1215,13 @@ const baseDefaultPermissionSets: PermissionSet[] = [ operation: 'select', using: 'email == current_user.email', }, - // [#20001] Repeated here rather than inherited from `member_default`: this - // set names the SCIM projection tables through the blanket, and wherever - // the platform baseline is not composed a set with no policy for them - // leaves them unfiltered. See `scimProjectionRowScope` above. - ...scimProjectionRowScope(), - // [#20027] Repeated here for the same reason: the credential tables, no row. - ...privateCredentialRowScope(), - // [ADR-0131 D7] Repeated here for the same reason: the compliance ledger's - // organization scope, which this set's wildcard read reaches. + // [#20001, #20027] The SCIM projection and credential tables, which this + // set names through the blanket, read under their objects' own declared + // scope — see "Identity tables scoped by their own declaration" above. + // [ADR-0131 D7] Repeated here rather than inherited from `member_default`, + // which is not resolved where no platform baseline is composed: the + // compliance ledger's organization scope, which this set's wildcard read + // reaches. See `auditLedgerRowScope` above for why the copy stays. ...auditLedgerRowScope(), ], }), @@ -1376,8 +1230,11 @@ const baseDefaultPermissionSets: PermissionSet[] = [ // The capability ceiling an OAuth-authenticated MCP agent runs under, // derived from the token's consented scopes (see // `scopesToAgentPermissionSets`). These are ONE SIDE of the D10 intersection: - // the delegating user's own sets provide all row/owner/tenant narrowing, so - // these carry pure CRUD bits and NO row-level security. They are never bound + // the delegating user's own leg provides all row/owner/tenant narrowing (its + // sets' policies, and each object's declared default row scope where those + // carry none — the ceiling's own leg collects no default, ruling of record + // 6278784335 on #24341, letter B), so these carry pure CRUD bits and NO + // row-level security. They are never bound // to a position or an audience anchor — the producer // (`resolve-execution-context`) injects them onto the agent principal's // context directly — so the anchor high-privilege gate does not apply. 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 fcf26d47df8..b4e1518f70e 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 @@ -15,7 +15,11 @@ * 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. + * same predicate. The thirteen whose copies the shipped sets no longer + * carry (ruling E′ item 3's follow-up: the SCIM projection and credential + * tables, and the `select` self scope of `sys_account`, `sys_team_member` + * and the two OAuth token tables) declare the literal written out here, + * and no shipped set carries a policy for them. * 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 @@ -24,10 +28,15 @@ * `service-automation/src/sys-user-default-row-scope.integration.test.ts`. * 4. The security plugin's collection applies a declared default where the * caller's held sets carry no row policy for the object and operation — - * never on a delegated principal's own leg — and the shipped sets keep - * their spreads: a deployment with the shipped baseline serves every - * persona, delegated agents included, the rows and the explain it served - * before the declarations. + * never on a delegated principal's own leg. On the ten declarers whose + * literal the shipped sets still carry, a deployment with the shipped + * baseline serves every persona, delegated agents included, the rows and + * the explain it served before the declarations; on the thirteen whose + * copies they dropped, the declaration is the scope every persona below + * platform admin reads under (the undeclared tree reads wider), which is + * exactly where the census finds a default collected. That the shipped + * tree serves those thirteen what it served with the copies is pinned in + * `shipped-sets-identity-default-row-scope.test.ts`. */ import { describe, it, expect, vi, afterAll } from 'vitest'; @@ -86,6 +95,21 @@ const DECLARERS = [ 'sys_audit_log', ] as const; +/** + * The declarers whose copies the shipped sets no longer carry (ruling E′ item + * 3's follow-up), with the literal each declares: `_self` on the tables that + * name the user a row is about, `_none` (`id == null`, no row) on the rest. + */ +const DROPPED_COPIES: Record = Object.fromEntries([ + ...['sys_scim_user', 'sys_scim_subject', 'sys_scim_projection_grant', 'sys_scim_identity_tombstone', + 'sys_account', 'sys_team_member', 'sys_oauth_access_token', 'sys_oauth_refresh_token'] + .map((o) => [o, { name: `${o}_self`, operation: 'select', using: 'user_id == current_user.id' }]), + ...['sys_scim_group', 'sys_scim_group_member', 'sys_scim_connection_binding', 'sys_verification', 'sys_jwks'] + .map((o) => [o, { name: `${o}_none`, operation: 'select', using: 'id == null' }]), +]); +/** The declarers whose literal the shipped sets still carry. */ +const KEPT_IN_SETS = DECLARERS.filter((n) => !(n in DROPPED_COPIES)); + /** 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)$/; @@ -142,7 +166,7 @@ describe('the platform identity objects declare their default row scope (ruling for (const name of platformGlobal) expect(objects.get(name)!.defaultRowScope, name).toBeUndefined(); }); - it.each(DECLARERS.filter((n) => n !== 'sys_user'))( + it.each(KEPT_IN_SETS.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!; @@ -166,6 +190,16 @@ describe('the platform identity objects declare their default row scope (ruling }, ); + it.each(Object.keys(DROPPED_COPIES))( + '%s declares its scope, and no shipped set carries a copy of it (ruling E′ item 3\'s follow-up)', + async (object) => { + const declared = (await registeredObjects()).get(object)!.defaultRowScope!; + expect(asLiteral(object, declared)).toEqual({ ...DROPPED_COPIES[object], object, enabled: true }); + expect('object' in declared).toBe(false); + for (const set of defaultPermissionSets) expect(policiesFor(set.name, object), set.name).toEqual([]); + }, + ); + 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']) { @@ -426,19 +460,19 @@ async function bootBaseline(declared: boolean) { 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)', () => { +describe('a deployment with the shipped baseline is byte-for-byte unchanged on the real declarations where the sets carry the literal (ruling E′ item 6, ③.11)', () => { // Explain is read for `read`; the census below reads the collection itself, // the one source every door and every explain composes from, for all four. const EXPLAINED = ['read'] as const; const COLLECTED = ['select', 'insert', 'update', 'delete'] as const; - it('every persona reads every declarer exactly as without the declarations, and is explained the same', { timeout: 30_000 }, async () => { + it('every persona reads every declarer the sets still carry 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) { + for (const object of KEPT_IN_SETS) { const label = `${persona} on ${object}`; const was = await before.served(object, context); const now = await after.served(object, context); @@ -460,7 +494,17 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t expect(await after.served('sys_session', BASELINE_PERSONAS.platform_admin)).toHaveLength(BASELINE_USERS.length); }); - it('each delegated agent reads its delegator\'s rows on both trees: the platform administrator\'s agent reads every row', { timeout: 30_000 }, async () => { + it('on the declarers whose copies the sets dropped, the declaration is the scope: without it the baseline member reads every row', { timeout: 30_000 }, async () => { + const after = await bootBaseline(true); + const before = await bootBaseline(false); + for (const object of Object.keys(DROPPED_COPIES)) { + const now = await after.served(object, BASELINE_PERSONAS.member); + expect(now, object).toEqual(DROPPED_COPIES[object].name.endsWith('_self') ? [`${object}:u_x`] : []); + expect(await before.served(object, BASELINE_PERSONAS.member), object).toHaveLength(BASELINE_USERS.length); + } + }); + + it('each delegated agent reads its delegator\'s rows on both trees where the sets carry the literal, and under the declaration where they do not: 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(); @@ -481,13 +525,17 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t }; 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); + // `sys_scim_user`'s copy the shipped sets dropped: its declaration is the + // delegator leg's scope, so the undeclared tree is no "before" for it. + if (!(object in DROPPED_COPIES)) { + 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 () => { + it('census: a persona collects a declared default exactly on the dropped copies\' `select` — the baseline carries a policy for every other declarer — and a delegated leg collects none', async () => { const { plugin } = await bootBaseline(true); const objects = await registeredObjects(); const census = async (context: Persona): Promise => { @@ -501,10 +549,12 @@ describe('a deployment with the shipped baseline is byte-for-byte unchanged on t } return applied; }; + const dropped = Object.keys(DROPPED_COPIES).map((object) => `${object} select`).sort(); 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([]); + // [letter B] A delegated agent's own leg — the ceiling — collects none. + expect((await census(context)).sort(), persona).toEqual(context.onBehalfOf ? [] : dropped); } // 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 diff --git a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts index d396389cb17..4713483827e 100644 --- a/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts +++ b/packages/plugins/plugin-security/src/objects/rbac-objects.test.ts @@ -173,7 +173,6 @@ describe('default permission sets', () => { expect(policyNames).toEqual([ 'owner_only_deletes', 'owner_only_writes', - 'sys_account_self', 'sys_api_key_self', // [ADR-0131 D7] The compliance ledger's organization scope on its // attribution field `tenant_id`: the ledger has no tenant column, so @@ -209,39 +208,25 @@ describe('default permission sets', () => { // deliberate, not a duplicate to be collapsed. 'sys_invitation_issuer', 'sys_invitation_self', - // [#20027] The credential tables' row scope (`id == null`, no row) — - // re-judged here, not a drift. `sys_jwks` and `sys_verification` declare - // `access: private` and "DENY for non-admins", but the managed-object - // blanket's EXPLICIT read entry opened them to every set holding it. - // Behaviour is pinned end to end in `private-credential-row-scope.test.ts`; - // this list only records presence (its twin is last in the list). - 'sys_jwks_none', + // [#24164 E′ item 3] Absent by design: the row scope of the two + // credential tables (`_none`), the seven SCIM projection tables and the + // `select` self scope of `sys_account`, `sys_team_member` and the two + // OAuth token tables. Each is now its object's own declared + // `defaultRowScope`, which the collection applies where no held set + // carries a policy — so a copy here would change no row and only move + // explain back to this set. Behaviour is pinned end to end in + // `shipped-sets-identity-default-row-scope.test.ts`, + // `private-credential-row-scope.test.ts` and + // `scim-projection-row-scope.test.ts`; this list only records presence. 'sys_notification_receipt_self', - 'sys_oauth_access_token_self', 'sys_oauth_application_self', 'sys_oauth_consent_self', - 'sys_oauth_refresh_token_self', 'sys_organization_self', - // [#20001] The SCIM projection tables' row scope — re-judged here, not a - // drift: none of the seven carries a tenant column, so Layer 0 is inert - // on them and the managed-object blanket read every organization's rows. - // `_self` on the four that name a user; `_none` (`id == null`, no row) on - // the three that name none. Their behaviour is pinned end to end in - // `scim-projection-row-scope.test.ts`; this list only records presence. - 'sys_scim_connection_binding_none', - 'sys_scim_group_member_none', - 'sys_scim_group_none', - 'sys_scim_identity_tombstone_self', - 'sys_scim_projection_grant_self', - 'sys_scim_subject_self', - 'sys_scim_user_self', 'sys_session_self', - 'sys_team_member_self', 'sys_two_factor_self', 'sys_user_org_members', 'sys_user_preference_self', 'sys_user_self', - 'sys_verification_none', ]); expect(policyNames).not.toContain('tenant_isolation'); const orgSelf = (member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_organization_self')!; diff --git a/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts b/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts new file mode 100644 index 00000000000..e7edb8c0f35 --- /dev/null +++ b/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts @@ -0,0 +1,527 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ruling of record 6250748677 on #24164, letter E′ item 3 — the follow-up it + * names] The shipped permission sets no longer repeat the row scope thirteen + * identity objects declare as their own `defaultRowScope`: the seven SCIM + * projection tables, the two credential tables, and the `select` self scope of + * `sys_account`, `sys_team_member`, `sys_oauth_access_token` and + * `sys_oauth_refresh_token`. Each dropped copy was byte-identical to its + * object's declaration, and no shipped set carries a `'*'` read policy, so a + * holder's `select` collection on those objects is now empty and collects the + * object's default (`SecurityPlugin.collectRLSPolicies`) — the same literal. + * + * Five pins, booted on the real `SecurityPlugin` + `ObjectQL` over a real + * `SqlDriver`, with the real declarations and the SHIPPED sets: + * 1. Rows do not move. Every persona — the set holders, a user in no + * organization, the delegated agents (ruling of record 6278784335 on + * #24341, letter B) — is served exactly the ids listed below on every + * touched object, and exactly what the same deployment served with the + * dropped copies spread back into the sets. Three deployment shapes: the + * stock walled one, a walled one composing NO platform baseline (each + * principal resolves only the set it names — the shape the copies were + * repeated for), and `single`. + * 2. Explain moves, as ruled: where a dropped copy used to be the row + * predicate, explain names the object's default (`via: 'object_default'`); + * the collection census says the same for every dropped object. + * 3. What stays in the sets stays: `sys_user_self` (`all`) and every `all` + * self scope whose write arm differs from the `'*'` ownership floor, and + * the compliance ledger's `sys_audit_log_org`, whose copy is what makes + * the object's identical default a PLATFORM tenant policy + * (`platform-tenant-policies.ts` keys provenance off the shipped sets), + * so `single` strips it as it strips the copy. + * 4. A set that authors its own policy for one of these objects has it used + * as authored: the object's default is not added beside it. + * 5. No shipped set carries a policy for a dropped object. + */ + +import { describe, it, expect, vi, afterAll, beforeAll } from 'vitest'; +import type { PermissionSet } from '@objectstack/spec/security'; +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 { SecurityPlugin } from '../security-plugin.js'; +import { isObjectDefaultRowScopePolicy } from '../object-default-row-scope.js'; +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from '../__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './sys-user-position.object.js'; +import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; +import { bindTestSecurityCatalog } from '../__tests__/security-catalog.testkit.js'; + +type Policy = { name: string; object?: string; operation: string; using?: string; enabled?: boolean }; +type ObjectDef = { name: string; fields?: Record; defaultRowScope?: Policy }; +type Persona = Record; + +/** The dropped copies, by the family that carried each. Every one names its object's declared default. */ +const DROPPED = { + scim: [ + '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', + ], + credential: ['sys_verification', 'sys_jwks'], + self: ['sys_account', 'sys_team_member', 'sys_oauth_access_token', 'sys_oauth_refresh_token'], +} as const; +const DROPPED_OBJECTS: readonly string[] = [...DROPPED.scim, ...DROPPED.credential, ...DROPPED.self]; + +/** Objects whose rows name the user they are about: a principal reads its own row. */ +const USER_KEYED = new Set([...DROPPED.scim.slice(0, 4), ...DROPPED.self]); +/** Objects no principal below platform admin reads a row of. */ +const NO_ROW = new Set([...DROPPED.scim.slice(4), ...DROPPED.credential]); +/** The touched objects that declare `access: { default: 'private' }`. */ +const PRIVATE = new Set(['sys_verification', 'sys_jwks', 'sys_oauth_access_token', 'sys_oauth_refresh_token']); +const LEDGER = 'sys_audit_log'; +/** Every object this pin reads: the dropped thirteen and the ledger, whose copy stays. */ +const TOUCHED: readonly string[] = [...DROPPED_OBJECTS, LEDGER]; + +/** The `all` self scopes the sets keep (their write arm differs from the `'*'` ownership floor). */ +const KEPT_ALL = [ + 'sys_session', + 'sys_two_factor', + 'sys_user_preference', + 'sys_api_key', + 'sys_device_code', + 'sys_oauth_consent', + 'sys_oauth_application', + 'sys_organization', +] as const; + +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; +} + +/** The sets that spread the dropped copies (`organization_admin_no_bypass` is derived from the first). */ +const SPREADING_SETS = ['organization_admin', 'organization_admin_no_bypass', 'member_default', 'viewer_readonly']; + +/** + * The shipped sets with the dropped copies spread back in, each as the object's + * declaration spells it plus `object` — the literal the sets carried. A set that + * already carries one (the tree before this change) is left as it is. + */ +function withCopiesRestored(objects: Map): PermissionSet[] { + return defaultPermissionSets.map((set) => { + if (!SPREADING_SETS.includes(set.name)) return set; + const held = new Set((set.rowLevelSecurity ?? []).map((p) => p.object)); + const copies = DROPPED_OBJECTS.filter((o) => !held.has(o)).map((o) => ({ + ...objects.get(o)!.defaultRowScope!, + object: o, + enabled: true, + })); + return { ...set, rowLevelSecurity: [...(set.rowLevelSecurity ?? []), ...copies] } as PermissionSet; + }); +} + +/** An application set that authors its own `select` policy for `sys_account`: no row. */ +const AUTHORED_ACCOUNT_SET = { + name: 'qa_account_authored', + label: 'QA — authored sys_account scope', + objects: {}, + rowLevelSecurity: [{ name: 'qa_sys_account_none', object: 'sys_account', operation: 'select', using: 'id == null', enabled: true }], +} as unknown as PermissionSet; + +/** Organization X: `u_x`, `u_x2`, `a_x`. Organization Y: `u_y`. `u_loner` belongs to none. */ +const ORG_X_USERS = ['u_x', 'u_x2', 'a_x']; +const USERS = ['u_x', 'u_x2', 'a_x', 'u_y', 'u_loner', 'p_admin']; +const ORG_OF: Record = { u_x: 'org_x', u_x2: 'org_x', a_x: 'org_x', u_y: 'org_y', u_loner: null, p_admin: null }; + +const human = (userId: string, positions: string[], permissions: string[], posture: string): Persona => ({ + userId, tenantId: 'org_x', positions, permissions, posture, org_user_ids: ORG_X_USERS, email: `${userId}@example.test`, +}); +/** An MCP OAuth agent as core's `assembleExecutionContext` shapes it: `userId` is the delegator (ADR-0090 D10). */ +const agent = (userId: string, posture: string): Persona => ({ + userId, tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_read'], principalKind: 'agent', + onBehalfOf: { userId, principalKind: 'human' }, posture, org_user_ids: ORG_X_USERS, email: `${userId}@example.test`, +}); + +const PERSONAS: Record = { + member_default: human('u_x', ['org_member'], ['member_default'], 'MEMBER'), + viewer_readonly: human('u_x2', ['org_member'], ['viewer_readonly'], 'MEMBER'), + organization_admin: human('a_x', ['org_admin'], ['organization_admin'], 'TENANT_ADMIN'), + organization_admin_no_bypass: human('a_x', ['org_admin'], ['organization_admin_no_bypass'], 'TENANT_ADMIN'), + // The resolver's answer for a user in no organization: no positions, no sets + // of their own, the peer list seeded with themself alone. + 'user with no organization': { + userId: 'u_loner', positions: [], permissions: [], posture: 'MEMBER', org_user_ids: ['u_loner'], email: 'u_loner@example.test', + }, + 'mcp agent for the member': agent('u_x', 'MEMBER'), + 'mcp agent for the viewer': agent('u_x2', 'MEMBER'), + 'mcp agent for the organization admin': agent('a_x', 'TENANT_ADMIN'), + platform_admin: { userId: 'p_admin', tenantId: 'org_x', positions: [], permissions: ['admin_full_access'], posture: 'PLATFORM_ADMIN' }, +}; + +interface Shape { + label: string; + posture: 'isolated' | 'single'; + baseline: boolean; + personas: string[]; +} + +const HUMANS = ['member_default', 'viewer_readonly', 'organization_admin', 'organization_admin_no_bypass']; +const AGENTS = ['mcp agent for the member', 'mcp agent for the viewer', 'mcp agent for the organization admin']; +const SHAPES: Shape[] = [ + { + label: 'isolated posture, platform baseline composed (the stock shape)', + posture: 'isolated', baseline: true, + personas: [...HUMANS, 'user with no organization', ...AGENTS, 'platform_admin'], + }, + { + // The shape the copies were repeated for: each principal resolves only the + // set it names, so the object's default is the only scope a set no longer + // carrying the copy can lean on. + label: 'isolated posture, NO platform baseline (each set on its own)', + posture: 'isolated', baseline: false, + personas: [...HUMANS, 'mcp agent for the viewer', 'mcp agent for the organization admin', 'platform_admin'], + }, + { + label: 'single posture, platform baseline composed', + posture: 'single', baseline: true, + personas: [...HUMANS, 'user with no organization', ...AGENTS, 'platform_admin'], + }, +]; + +/** 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 ledger's rows: one about each organization, one about a deployment-level action. */ +const LEDGER_ROWS = { org_x: 'sys_audit_log:org_x', org_y: 'sys_audit_log:org_y', deployment: 'sys_audit_log:deployment' }; + +/** The rows written for `object`: one per user, each user's own where it names one, in that user's organization. */ +function fixtureRowsFor(schema: ObjectDef): Array> { + if (schema.name === 'sys_user') return 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 })); + if (schema.name === LEDGER) { + return [ + rowOf(schema, { id: LEDGER_ROWS.org_x, tenant_id: 'org_x', user_id: 'u_x' }), + rowOf(schema, { id: LEDGER_ROWS.org_y, tenant_id: 'org_y', user_id: 'u_y' }), + rowOf(schema, { id: LEDGER_ROWS.deployment, tenant_id: null, user_id: 'p_admin' }), + ]; + } + return USERS.map((user) => rowOf(schema, { id: `${schema.name}:${user}`, user_id: user, organization_id: ORG_OF[user] })); +} + +const engines: ObjectQL[] = []; +afterAll(async () => { + for (const engine of engines.splice(0)) { + try { await engine.destroy(); } catch { /* noop */ } + } +}); + +async function boot(shape: Shape, sets: PermissionSet[]) { + const objects = await registeredObjects(); + const schemas = [...new Set([...TOUCHED, ...KEPT_ALL, 'sys_user', 'sys_member'])].map((n) => objects.get(n)!); + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.shipped-sets-identity-default-row-scope', + name: 'Shipped sets identity default row scope', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [...schemas, 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 = { + ...(shape.posture === 'isolated' ? { 'org-scoping': { name: 'com.objectstack.org-scoping' } } : {}), + tenancy: { posture: shape.posture }, + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async (_t: string, name: string) => engine.getSchema(name) ?? null, list: async () => sets }, + }; + 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]; + }, + }; + const plugin = new SecurityPlugin({ defaultPermissionSets: sets, ...(shape.baseline ? {} : { fallbackPermissionSet: null }) }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + // The catalog the grants name their sets in (ADR-0131 D3/D4). + bindTestSecurityCatalog(engine, { permissions: sets 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 schemas) if (schema.name !== 'sys_member') await knex(schema.name).insert(fixtureRowsFor(schema)); + // What an agent's delegator resolves from (ADR-0090 D10: the delegator leg is + // rebuilt from the store): the memberships, and the grants the viewer and the + // administrator 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', organization_id: 'org_x', + permission_set: shape.posture === 'single' ? 'organization_admin_no_bypass' : 'organization_admin', + }, + { 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}`; + } + }; + /** The (object, operation) pairs at which `persona`'s collection is an object default. */ + const census = async (persona: Persona, objectNames: readonly string[], operations: readonly string[]): Promise => { + const held = await (plugin as any).resolvePermissionSetsForContext({ ...persona }); + const applied: string[] = []; + for (const object of objectNames) { + for (const operation of operations) { + const collected = (plugin as any).collectRLSPolicies(held, object, operation, (persona.positions ?? []) as string[], { ...persona }); + if (collected.some((p: object) => isObjectDefaultRowScopePolicy(p))) applied.push(`${object} ${operation}`); + } + } + return applied; + }; + /** The policy names `persona`'s collection carries for `object` and `operation`. */ + const collectedNames = async (persona: Persona, object: string, operation: string): Promise => { + const held = await (plugin as any).resolvePermissionSetsForContext({ ...persona }); + const collected = (plugin as any).collectRLSPolicies(held, object, operation, (persona.positions ?? []) as string[], { ...persona }); + return collected.map((p: Policy) => `${p.name}${isObjectDefaultRowScopePolicy(p) ? ' (object default)' : ''}`).sort(); + }; + return { engine, security, served, census, collectedNames }; +} + +/** The user whose rows a persona reads as its own: the delegator, for an agent. */ +const selfOf = (persona: Persona): string => + String((persona.onBehalfOf as { userId?: string } | undefined)?.userId ?? persona.userId); + +/** + * The ids each persona is served on each touched object, written out per + * family. The same answer before the copies were dropped and after: the pin + * runs unchanged on both trees. + */ +function expectedServed(shape: Shape, personaName: string, object: string): string[] | string { + const persona = PERSONAS[personaName]; + const everyRow = (o: string) => (o === LEDGER ? Object.values(LEDGER_ROWS) : USERS.map((u) => `${o}:${u}`)).sort(); + if (personaName === 'platform_admin') return everyRow(object); + // A read ceiling's `'*'` does not reach a `private` object (ADR-0090 D10's + // listed subtraction): the agent is refused there, before any row scope. + if (persona.principalKind === 'agent' && PRIVATE.has(object)) return 'refused PERMISSION_DENIED 403'; + if (USER_KEYED.has(object)) return [`${object}:${selfOf(persona)}`]; + if (NO_ROW.has(object)) return []; + // The ledger: `member_default` grants no read on it, so a member — and an + // agent bounded by a member — is refused at the object layer; a viewer's + // wildcard read and an org admin's explicit entry reach it, scoped to the + // active organization under a wall and unscoped under `single`, where the + // platform's tenant policy is stripped (ADR-0105 D3). + const reader = ['viewer_readonly', 'organization_admin', 'organization_admin_no_bypass', + 'mcp agent for the viewer', 'mcp agent for the organization admin'].includes(personaName); + if (!reader) return 'refused PERMISSION_DENIED 403'; + return shape.posture === 'single' ? everyRow(LEDGER) : [LEDGER_ROWS.org_x]; +} + +describe.each(SHAPES)('the shipped sets serve the identity objects as before — $label', (shape) => { + let shipped: Awaited>; + let restored: Awaited>; + beforeAll(async () => { + shipped = await boot(shape, defaultPermissionSets); + restored = await boot(shape, withCopiesRestored(await registeredObjects())); + }); + + it('every persona is served exactly the listed ids on every touched object', async () => { + const wrong: string[] = []; + for (const persona of shape.personas) { + for (const object of TOUCHED) { + const now = await shipped.served(object, PERSONAS[persona]); + const want = expectedServed(shape, persona, object); + if (JSON.stringify(now) !== JSON.stringify(want)) wrong.push(`${persona} on ${object}: ${JSON.stringify(now)} (expected ${JSON.stringify(want)})`); + } + } + expect(wrong).toEqual([]); + }); + + it('…and exactly what the same deployment serves with the dropped copies spread back into the sets', async () => { + const moved: string[] = []; + for (const persona of shape.personas) { + for (const object of [...TOUCHED, ...KEPT_ALL, 'sys_user']) { + const was = await restored.served(object, PERSONAS[persona]); + const now = await shipped.served(object, PERSONAS[persona]); + if (JSON.stringify(now) !== JSON.stringify(was)) moved.push(`${persona} on ${object}: ${JSON.stringify(was)} -> ${JSON.stringify(now)}`); + } + } + expect(moved).toEqual([]); + }); + + it('CONTROL: the fixture holds rows a scoped persona must not read — the platform administrator reads them all', async () => { + for (const object of DROPPED_OBJECTS) { + expect(await shipped.served(object, PERSONAS.platform_admin), object).toHaveLength(USERS.length); + } + expect(await shipped.served(LEDGER, PERSONAS.platform_admin)).toHaveLength(3); + }); +}); + +describe('explain names the object default where a dropped copy used to be the row predicate (ruling E′ item 3)', () => { + const shape = SHAPES[0]; + let shipped: Awaited>; + let restored: Awaited>; + beforeAll(async () => { + shipped = await boot(shape, defaultPermissionSets); + restored = await boot(shape, withCopiesRestored(await registeredObjects())); + }); + type RlsLayer = { layer: string; verdict: string; detail: string; record?: { rules: Array> } }; + const rlsLayer = (decision: { layers: RlsLayer[] }): RlsLayer => { + const layer = decision.layers.find((l) => l.layer === 'rls'); + if (!layer) throw new Error('explain returned no rls layer'); + return layer; + }; + + it.each([ + ['SCIM', 'sys_scim_user', 'sys_scim_user_self'], + ['self', 'sys_account', 'sys_account_self'], + ])('%s family: a member\'s read of its own %s row is explained by the object default %s', async (_family, object, policy) => { + const member = PERSONAS.member_default; + const request = { object, operation: 'read', recordId: `${object}:u_x` }; + const now = rlsLayer(await shipped.security.explain(request, { ...member })); + expect(now.record?.rules).toEqual([expect.objectContaining({ kind: 'rls_policy', name: policy, via: 'object_default', effect: 'admits' })]); + // The tree with the copy: the same verdict, attributed to the held set. + const was = rlsLayer(await restored.security.explain(request, { ...member })); + expect(was.record?.rules).toEqual([expect.objectContaining({ kind: 'rls_policy', name: 'business_rls', effect: 'admits' })]); + expect(was.record?.rules?.[0]?.via).toBeUndefined(); + }); + + it.each([ + ['SCIM', 'sys_scim_group', 'sys_scim_group_none'], + ['credential', 'sys_verification', 'sys_verification_none'], + ['credential', 'sys_jwks', 'sys_jwks_none'], + ])('%s family: the object-level read of %s names the object default %s as its source', async (_family, object, policy) => { + for (const persona of HUMANS) { + const now = rlsLayer(await shipped.security.explain({ object, operation: 'read' }, { ...PERSONAS[persona] })); + expect(now.detail, persona).toContain(`object default row scope '${policy}'`); + const was = rlsLayer(await restored.security.explain({ object, operation: 'read' }, { ...PERSONAS[persona] })); + expect(was.detail, persona).not.toContain('object default'); + expect(now.verdict, persona).toBe(was.verdict); + } + }); + + it('census: every human persona collects the object default on exactly the dropped objects\' `select`; a delegated leg collects none', async () => { + const COLLECTED = ['select', 'insert', 'update', 'delete'] as const; + const every = [...DROPPED_OBJECTS, LEDGER, ...KEPT_ALL, 'sys_user']; + for (const persona of [...HUMANS, 'user with no organization']) { + expect(await shipped.census(PERSONAS[persona], every, COLLECTED), persona).toEqual(DROPPED_OBJECTS.map((o) => `${o} select`)); + // CONTROL: with the copies spread back no persona collects a default. + expect(await restored.census(PERSONAS[persona], every, COLLECTED), `${persona}, copies restored`).toEqual([]); + } + // [ruling B] The agent's own leg — the ceiling — collects no default. + for (const persona of AGENTS) expect(await shipped.census(PERSONAS[persona], every, COLLECTED), persona).toEqual([]); + }); +}); + +describe('what the sets keep (pointer 6280763190, must-keep; the ledger, measured)', () => { + let walled: Awaited>; + beforeAll(async () => { + walled = await boot(SHAPES[0], defaultPermissionSets); + }); + + it('a member\'s write collection on each kept `all` object carries the set\'s self scope beside the ownership floor, never the default', async () => { + const member = PERSONAS.member_default; + for (const object of [...KEPT_ALL, 'sys_user']) { + const self = object === 'sys_user' ? 'sys_user_self' : `${object}_self`; + for (const operation of ['update', 'delete']) { + const floor = operation === 'update' ? 'owner_only_writes' : 'owner_only_deletes'; + expect(await walled.collectedNames(member, object, operation), `${object} ${operation}`).toEqual([floor, self].sort()); + } + } + }); + + it('`sys_user_self` stays `all` in member_default, and the ledger copy stays in every set that spread it', () => { + const member = defaultPermissionSets.find((s) => s.name === 'member_default')!; + expect((member.rowLevelSecurity ?? []).find((p) => p.name === 'sys_user_self')?.operation).toBe('all'); + for (const setName of SPREADING_SETS) { + const set = defaultPermissionSets.find((s) => s.name === setName)!; + expect((set.rowLevelSecurity ?? []).filter((p) => p.object === LEDGER).map((p) => p.name), setName).toEqual(['sys_audit_log_org']); + } + }); +}); + +describe('an authored policy for one of these objects is used as authored (ruling E′ item 2)', () => { + let shipped: Awaited>; + beforeAll(async () => { + shipped = await boot(SHAPES[0], [...defaultPermissionSets, AUTHORED_ACCOUNT_SET]); + }); + + // While `member_default` carried its `sys_account_self` copy, that copy was + // OR-ed in beside the authored policy and this holder read its own row: an + // authored scope leaving out the holder's own rows was widened back to them. + it('a member holding a set that authors a `sys_account` scope is served what that scope admits — the object default is not added beside it', async () => { + const holder = { ...PERSONAS.member_default, permissions: ['member_default', AUTHORED_ACCOUNT_SET.name] }; + expect(await shipped.served('sys_account', holder)).toEqual([]); + expect(await shipped.collectedNames(holder, 'sys_account', 'select')).toEqual(['qa_sys_account_none']); + // CONTROL: the same member without the authored set reads its own row under the default. + expect(await shipped.served('sys_account', PERSONAS.member_default)).toEqual(['sys_account:u_x']); + }); +}); + +describe('no shipped set carries a policy for a dropped object', () => { + it('CONTROL: the fixture\'s families read off the declarations — every dropped object declares a default, the private ones `private`', async () => { + const objects = await registeredObjects(); + for (const object of TOUCHED) expect(objects.get(object)?.defaultRowScope?.operation, object).toBe('select'); + const declaredPrivate = TOUCHED.filter( + (o) => (objects.get(o) as { access?: { default?: string } } | undefined)?.access?.default === 'private', + ); + expect(declaredPrivate.sort()).toEqual([...PRIVATE].sort()); + }); + + it.each(DROPPED_OBJECTS)('%s', (object) => { + for (const set of defaultPermissionSets) { + expect((set.rowLevelSecurity ?? []).filter((p) => p.object === object).map((p) => p.name), set.name).toEqual([]); + } + }); +}); diff --git a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts index e5448e8b2a4..32183dcd3a6 100644 --- a/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/private-credential-row-scope.test.ts @@ -16,9 +16,12 @@ * policy the holder's set declares, and neither object had one. * * The fix is the header's own instrument, the per-object row scope: a `_none` - * policy (`id == null`, no row) on each object, in every shipped set that holds - * the blanket and carries row-level security. The two readers that must keep - * every row are controls here: + * scope (`id == null`, no row) on each object. It was first a policy in every + * shipped set that holds the blanket; it is now each object's own declared + * `defaultRowScope`, which the security plugin's collection applies wherever a + * holder's sets carry no row policy for the object (#24164 ruling E′ item 3's + * follow-up removed the sets' copies). The two readers that must keep every + * row are controls here: * - the platform admin (`admin_full_access`), through its superuser bypass; * - better-auth itself, which reads these tables through its adapter under * system context (`withSystemContext` in plugin-auth injects exactly the @@ -30,8 +33,7 @@ * `scim-projection-row-scope.test.ts`: * - `isolated` with the platform baseline composed (the stock walled shape); * - `isolated` with NO baseline, where each persona resolves only the set it - * names, so a set that carried no scope of its own reads every row even - * while the stock shape stays green through `member_default`; + * names, so the object's declared scope is the only one in force; * - `single`, where the policies carry no tenant token and so must survive * the posture's strip of the platform's tenant policies (ADR-0105 D3). */ @@ -183,11 +185,14 @@ const PERSONAS = { posture: 'TENANT_ADMIN', org_user_ids: ORG_X_USERS, email: emailOf(PEOPLE.admin), }, // 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. + // own by design (ADR-0090 D10), and its own leg collects no object default + // (ruling of record 6278784335 on #24341, letter B), so its bound is the + // delegating member's leg. That resolves 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. mcp_agent_data_write: { - userId: 'agent_x', tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_write'], - principalKind: 'agent', onBehalfOf: { userId: PEOPLE.member }, org_user_ids: ORG_X_USERS, + userId: PEOPLE.member, tenantId: 'org_x', positions: [], permissions: ['mcp_agent_data_write'], + principalKind: 'agent', onBehalfOf: { userId: PEOPLE.member, principalKind: 'human' }, org_user_ids: ORG_X_USERS, }, } as const; type Persona = keyof typeof PERSONAS; 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 5c6aeb17a5e..3dd12ae3feb 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 @@ -1,10 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The seven `@better-auth/scim` projection tables are row-scoped in every - * shipped permission set that holds the managed-object read grant — so no - * principal below platform admin reads a row another organization's identity - * provider provisioned. + * The seven `@better-auth/scim` projection tables are row-scoped for every + * holder of a shipped permission set that holds the managed-object read grant + * — so no principal below platform admin reads a row another organization's + * identity provider provisioned. The scope was first a policy in each such set; + * it is now each table's own declared `defaultRowScope`, which the security + * plugin's collection applies wherever a holder's sets carry no row policy for + * the table (#24164 ruling E′ item 3's follow-up removed the sets' copies). * * The class (see the header of `objects/default-permission-sets.ts`): every * object on `BETTER_AUTH_MANAGED_OBJECTS` gets an object-level `allowRead` from @@ -32,8 +35,7 @@ * path. Three deployment shapes: * - `isolated` with the platform baseline composed — the stock walled shape; * - `isolated` with NO baseline — each persona resolves only the set it - * names, so a set that carried no scope of its own would read every row - * here even while the stock shape stayed green through `member_default`; + * names, so the table's declared scope is the only one in force; * - `single` — the policies carry no tenant token, so the posture's strip of * the platform's tenant policies (ADR-0105 D3) must leave them in force. */ @@ -157,8 +159,9 @@ type Table = (name: string) => { insert(rows: Array>): P /** * One deployment shape. `composedBaseline: false` boots with no platform * baseline (`fallbackPermissionSet: null`), so each persona resolves ONLY the - * set it names — which is what proves every shipped set carries the scope - * itself, rather than borrowing it from `member_default`. + * set it names — which is what proves the scope reaches every shipped set's + * holder through the table's own declaration, rather than borrowed from + * `member_default`. */ interface Shape { label: string; @@ -264,9 +267,9 @@ const PERSONAS = { // 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). + // `userId` stays the delegating human. The ceiling's own leg collects no + // object default (ruling of record 6278784335 on #24341, letter B); the + // delegating member's leg reads each SCIM table under its declared scope. 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,