From a718adccb3f93fd628e9c375e7e9fe0fb50d2b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:46:21 +0000 Subject: [PATCH 1/3] feat(plugin-security): stage declared membership keys beside the registered resolver stageRlsMembership now resolves the `ObjectSchema.membershipKeys` the policies about to compile read: per key, lazily, memoized per request, as the platform's own Layer 0 system read of the roster object (caller's wall, no crossing, no Layer 1), bounded at MEMBERSHIP_KEY_MAX_VALUES, every miss failing closed. A key the registered resolver also owns, or that two objects declare, resolves from neither, with an error at boot and request. Explain names each key the rls layer reads (label, description, roster object, fields, filter, size or miss, and the roster's writers), and a record-grained rule reads via 'membership:KEY' with the linking roster row. objectql: registerObject refuses an `extend` contribution carrying `membershipKeys` (ExtensionMembershipKeysRefusedError), beside the defaultRowScope refusal; the merge never carried the key. The shared Layer 0 input builder moves to tenant-layer.ts (tenantLayer0InputOf) so the roster read and the read door build one input. Liveness for membershipKeys flips planned -> live. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339 --- .changeset/25234-membership-keys-staged.md | 19 + content/docs/references/api/metadata.mdx | 2 +- content/docs/references/data/object.mdx | 2 +- content/docs/references/security/rls.mdx | 5 +- content/docs/references/system/migration.mdx | 4 +- ...-extension-membership-keys-refused.test.ts | 165 ++++++ packages/objectql/src/registry.ts | 61 ++ .../plugin-security/src/explain-engine.ts | 110 +++- .../src/object-membership-keys.test.ts | 426 ++++++++++++++ .../src/object-membership-keys.ts | 537 ++++++++++++++++++ .../src/security-plugin-rls-filter.ts | 77 ++- .../plugin-security/src/security-plugin.ts | 40 +- .../plugin-security/src/tenant-layer.ts | 55 ++ packages/spec/liveness/object.json | 38 +- packages/spec/liveness/state-counts/object.md | 2 +- packages/spec/src/data/object.zod.ts | 19 +- packages/spec/src/security/rls.zod.ts | 10 +- 17 files changed, 1493 insertions(+), 79 deletions(-) create mode 100644 .changeset/25234-membership-keys-staged.md create mode 100644 packages/objectql/src/registry-extension-membership-keys-refused.test.ts create mode 100644 packages/plugins/plugin-security/src/object-membership-keys.test.ts create mode 100644 packages/plugins/plugin-security/src/object-membership-keys.ts diff --git a/.changeset/25234-membership-keys-staged.md b/.changeset/25234-membership-keys-staged.md new file mode 100644 index 00000000000..e67ec33dda6 --- /dev/null +++ b/.changeset/25234-membership-keys-staged.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-security': minor +'@objectstack/objectql': patch +'@objectstack/spec': patch +--- + +feat(plugin-security): a declared membership key is staged per request, so a policy reading `current_user.KEY` reads the roster + +Clause-②: no +Metadata-protocol: semantic + +`ObjectSchema.membershipKeys` is now applied. The security plugin's request staging (`stageRlsMembership`, the one producer of `ExecutionContext.rlsMembership`) gains the declarative second source beside the registered `rls-membership-resolver`: when a row policy about to compile reads `current_user.KEY` and an object declares `KEY`, the key is resolved from that roster object's rows. + +- **What the key holds.** The distinct values of `valueField` on the roster rows whose `userField` is the signed-in user and on which the optional `filter` holds, inside the caller's own organization wall. +- **How it is read.** Lazily and once per request: only a key a policy about to compile reads is resolved, on the read door and the write `check` alike, and one request that reads the key on several objects issues one read. The read is the platform's own system read: the caller needs no read on the roster object, and the caller's posture and audience do not change it (a portal contact and an internal member resolve a key the same way). The roster object's own row policies are not consulted. +- **Every miss fails closed.** No roster row stages an empty set. More than 1,000 values (`MEMBERSHIP_KEY_MAX_VALUES`), a failed read, a `filter` that does not compile for the roster object (a shape that does not lower, a column the object lacks, any `current_user.*`), a key two objects declare, and a key the registered resolver also owns leave the key unresolved. Every policy reading such a key matches no rows, through the compiler's existing warning. A key both sources claim is logged as an error at boot, naming both owners, and again by the request that meets it. +- **Explain.** The object-level `rls` layer names each key the policies read: its label and description, the roster object, `valueField`, `userField`, the filter, and the size it resolved to or why it did not, and the permission sets whose holders can write the roster object (whoever can write a roster row can grant the key). A record-grained rule reads `via: 'membership:KEY'`, and its detail names the roster row that links the record to the principal. +- **`@objectstack/objectql`: an `extend` contribution cannot declare a membership key.** `SchemaRegistry.registerObject` refuses one that carries `membershipKeys`, with `INVALID_METADATA` (`status: 422`) and the error name `ExtensionMembershipKeysRefusedError`, naming the object and the extending package, beside the `defaultRowScope` refusal. The extension merge never carried the key onto the object, so it registered and then never resolved. The manifest path already refused it at authoring. +- **`@objectstack/spec`**: the `membershipKeys` describe and the row-level security docs no longer say the key is not applied, and the liveness row is `live`. diff --git a/content/docs/references/api/metadata.mdx b/content/docs/references/api/metadata.mdx index 45aa5d4a0ce..425b799a95d 100644 --- a/content/docs/references/api/metadata.mdx +++ b/content/docs/references/api/metadata.mdx @@ -971,7 +971,7 @@ Metadata query with filtering, sorting, and pagination | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: the set is read once per request, as a system read inside the caller's organization and regardless of what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, scope rows with positions or a registered membership resolver. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 753a77b237a..e25e4399bf5 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -174,7 +174,7 @@ const result = ApiMethod.parse(data); | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: the set is read once per request, as a system read inside the caller's organization and regardless of what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, scope rows with positions or a registered membership resolver. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/content/docs/references/security/rls.mdx b/content/docs/references/security/rls.mdx index 9e88b9e743e..cbd396b2c8c 100644 --- a/content/docs/references/security/rls.mdx +++ b/content/docs/references/security/rls.mdx @@ -60,8 +60,9 @@ through one of two sources: - a **registered membership resolver** (ADR-0105 D11, the `rls-membership-resolver` service), code an installed plugin runs; or - a **declared membership key**: `ObjectSchema.membershipKeys` on the roster - object whose rows define the set. Not applied yet: the security plugin - does not read the declaration in this release. + object whose rows define the set, which the security plugin stages + per request beside the resolver's keys. A key both sources claim + resolves from neither. The platform stages no other key. A policy reading a key neither source supplies drops out and matches no rows (fail closed). diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 53b67a463da..a4d4316610a 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -356,7 +356,7 @@ Create a new object | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: the set is read once per request, as a system read inside the caller's organization and regardless of what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, scope rows with positions or a registered membership resolver. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | @@ -651,7 +651,7 @@ Create a new object | **enable** | `{ trackHistory?: boolean; searchable?: boolean; apiEnabled?: boolean; apiMethods?: Enum<'get' \| 'list' \| 'create' \| 'update' \| 'delete' \| 'bulk'>[]; … }` | optional | Enabled system features modules | | **sharingModel** | `Enum<'private' \| 'public_read' \| 'public_read_write' \| 'controlled_by_parent'>` | optional | Org-Wide Default record visibility (OWD) for INTERNAL users. Canonical four only (legacy aliases removed, ADR-0090 D4): private (owner-only) \| public_read (everyone reads, owner writes) \| public_read_write (everyone reads+writes) \| controlled_by_parent (derived from the master record). A CUSTOM object that omits this resolves to private at runtime (ADR-0090 D1). | | **defaultRowScope** | `{ name: string; label?: string; description?: string; operation: Enum<'select' \| 'insert' \| 'update' \| 'delete' \| 'all'>; … }` | optional | The object's default row scope: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object` (the declaring object is implied; writing `object` is refused). The security plugin's row-policy collection, the one step every read, write and explain passes through, applies it when no permission set the caller holds carries a row policy for this object and the operation: a held policy for every object (`'*'`) counts, while one for another operation, switched off, or outside the caller's positions does not. A policy an authored set carries for the object is used as authored, with this default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object that declares none keeps the behaviour it has without the key. The default is judged like a held policy: its `operation`, `positions` and `enabled` decide where it applies, and a `using` the row-filter compiler cannot lower makes every read it governs return no rows, with one warning in the server log. `os validate`, `os build` and `os lint` judge this predicate as they judge a permission set's policy and report one that cannot lower; saving the object through the server does not run that check, so keep it inside the lowerable subset. Explain names an applied default as the object's own, not a permission set's. | -| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: the set is read once per request, as a system read inside the caller's organization and regardless of what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, scope rows with positions or a registered membership resolver. | +| **membershipKeys** | `Record` | optional | Membership keys this object defines, by name: each makes `current_user.KEY` the set of `valueField` values on this object's rows whose `userField` is the signed-in user and on which the optional `filter` holds, for a row-level security policy to read with `in` (e.g. `supplier in current_user.portal_supplier_ids`, declared on a supplier-contact object as `portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }`). Declare a key on the one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is refused, and the kernel's own names (`id`, `organization_id`, `positions`, `org_user_ids`, `accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads the key, as a system read inside the caller's organization and regardless of what the caller may read on this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed read, a `filter` that does not compile for this object, or a key a registered membership resolver also owns makes every policy reading it match no rows. Whoever can write this object's rows can grant the key. | | **externalSharingModel** | `never` | optional | [REMOVED] `object.externalSharingModel` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it never had an effect: no request resolves an external principal, and the external rung of the authorization kernel (ADR-0095) admits explicitly shared rows only, never an org-wide default, so the value changed no answer for anyone. Delete the key. `sharingModel` is the one org-wide default; a principal reaches a row it does not own through an explicit share, and an anonymous reader through a share link (`publicSharing`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **publicSharing** | `{ enabled?: boolean; allowedAudiences?: Enum<'public' \| 'link_only' \| 'signed_in' \| 'email'>[]; allowedPermissions?: Enum<'view' \| 'comment' \| 'edit'>[]; maxExpiryDays?: integer; … }` | optional | Public share-link policy (Notion/Figma-style link sharing) | | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) | diff --git a/packages/objectql/src/registry-extension-membership-keys-refused.test.ts b/packages/objectql/src/registry-extension-membership-keys-refused.test.ts new file mode 100644 index 00000000000..743749233b0 --- /dev/null +++ b/packages/objectql/src/registry-extension-membership-keys-refused.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25234 ruling A, refusal 10] An `extend` contribution cannot declare a + * membership key: `registerObject` refuses one that carries `membershipKeys`, + * by name, before it opens the contributor list, beside the `defaultRowScope` + * refusal (`registry-extension-default-row-scope-refused.test.ts`). + * + * Without it the fold (`mergeObjectDefinitions`) dropped the key in silence: + * the extender's declaration registered, the resolved object never carried + * it, and nothing told the author the key would never resolve. + * + * Pinned on the envelope (`code` + `status`) and the error's `name`. The + * controls: an extension that leaves the key alone still registers and still + * folds, and the owner's own declaration of the key is untouched by either. + */ + +import { describe, it, expect } from 'vitest'; +import { EXTENSION_MEMBERSHIP_KEYS_REFUSED_CODE, SchemaRegistry } from './registry.js'; + +const OWNER_PKG = 'app.srm'; +const EXTENDER_PKG = 'app.extender'; +const OBJECT_NAME = 'srm_supplier_contact'; + +/** The owner's declaration — the roster object's own key. */ +const OWNER_KEYS = { + portal_supplier_ids: { userField: 'user', valueField: 'supplier', filter: 'active == true' }, +}; + +/** `null` registers an owner that declares no key. */ +function registryWithOwner(membershipKeys: unknown = OWNER_KEYS): SchemaRegistry { + const r = new SchemaRegistry({ multiTenant: false }); + r.logLevel = 'silent'; + r.registerObject( + { + name: OBJECT_NAME, + label: 'Supplier contact', + ...(membershipKeys === null ? {} : { membershipKeys }), + fields: { + user: { name: 'user', type: 'lookup', reference: 'sys_user', label: 'User' }, + supplier: { name: 'supplier', type: 'text', label: 'Supplier' }, + active: { name: 'active', type: 'boolean', label: 'Active' }, + }, + } as any, + OWNER_PKG, + ); + return r; +} + +function refusalOf(fn: () => unknown): any { + try { + fn(); + } catch (e) { + return e; + } + return undefined; +} + +describe('registerObject refuses an `extend` contribution that carries `membershipKeys`', () => { + it.each([ + ['adds a key of its own', { all_supplier_ids: { userField: 'user', valueField: 'supplier' } }], + ['repeats the owner\'s own key', OWNER_KEYS], + ['carries an empty map', {}], + ])('refuses an extension that %s, by name, and the object keeps the owner\'s keys', (_label, keys) => { + const r = registryWithOwner(); + const err = refusalOf(() => + r.registerObject( + { + name: OBJECT_NAME, + membershipKeys: keys, + fields: { nickname: { name: 'nickname', type: 'text', label: 'Nickname' } }, + } as any, + EXTENDER_PKG, + undefined, + 'extend', + ), + ); + + expect(err).toBeDefined(); + expect({ code: err.code, status: err.status, httpStatus: err.httpStatus, name: err.name }).toEqual({ + code: EXTENSION_MEMBERSHIP_KEYS_REFUSED_CODE, + status: 422, + httpStatus: 422, + name: 'ExtensionMembershipKeysRefusedError', + }); + expect(EXTENSION_MEMBERSHIP_KEYS_REFUSED_CODE).toBe('INVALID_METADATA'); + expect({ objectName: err.objectName, packageId: err.packageId }).toEqual({ + objectName: OBJECT_NAME, + packageId: EXTENDER_PKG, + }); + expect(err.message).toContain('`membershipKeys`'); + expect(err.message).toContain(EXTENDER_PKG); + + // Nothing of the refused contribution landed: no contributor, no field. + expect(r.getObjectContributors(OBJECT_NAME).map((c) => c.packageId)).toEqual([OWNER_PKG]); + const resolved = r.getObject(OBJECT_NAME) as any; + expect(resolved.membershipKeys).toEqual(OWNER_KEYS); + expect(resolved.fields.nickname).toBeUndefined(); + }); + + it('refuses it on an object whose owner declares no key too: the fold would never carry it', () => { + const r = registryWithOwner(null); + const err = refusalOf(() => + r.registerObject( + { name: OBJECT_NAME, membershipKeys: { portal_supplier_ids: { userField: 'user', valueField: 'supplier' } } } as any, + EXTENDER_PKG, + undefined, + 'extend', + ), + ); + expect({ code: err?.code, status: err?.status, name: err?.name }).toEqual({ + code: 'INVALID_METADATA', + status: 422, + name: 'ExtensionMembershipKeysRefusedError', + }); + expect((r.getObject(OBJECT_NAME) as any).membershipKeys).toBeUndefined(); + }); + + it('control: an extension that leaves `membershipKeys` alone registers and folds as before', () => { + const r = registryWithOwner(); + r.registerObject( + { + name: OBJECT_NAME, + label: 'Contact', + fields: { nickname: { name: 'nickname', type: 'text', label: 'Nickname' } }, + } as any, + EXTENDER_PKG, + undefined, + 'extend', + ); + // An explicitly-undefined key sets nothing, so it is not a contribution of one. + r.registerObject( + { name: OBJECT_NAME, membershipKeys: undefined, fields: { pronouns: { name: 'pronouns', type: 'text', label: 'Pronouns' } } } as any, + 'app.second-extender', + undefined, + 'extend', + ); + + expect(r.getObjectContributors(OBJECT_NAME).map((c) => c.packageId)).toEqual([ + OWNER_PKG, + EXTENDER_PKG, + 'app.second-extender', + ]); + const resolved = r.getObject(OBJECT_NAME) as any; + expect(resolved.label).toBe('Contact'); + expect(Object.keys(resolved.fields)).toEqual(expect.arrayContaining(['user', 'supplier', 'nickname', 'pronouns'])); + expect(resolved.membershipKeys).toEqual(OWNER_KEYS); + }); + + it('control: the owner\'s own `membershipKeys` registers, and re-registers, unrefused', () => { + const r = registryWithOwner(); + expect(() => + r.registerObject( + { + name: OBJECT_NAME, + label: 'Supplier contact', + membershipKeys: OWNER_KEYS, + fields: { user: { name: 'user', type: 'lookup', reference: 'sys_user', label: 'User' } }, + } as any, + OWNER_PKG, + ), + ).not.toThrow(); + expect((r.getObject(OBJECT_NAME) as any).membershipKeys).toEqual(OWNER_KEYS); + }); +}); diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index c45b8ab4945..bd7b446aed2 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -1526,6 +1526,61 @@ class ExtensionDefaultRowScopeRefusedError extends Error { } } +/** + * {@link ExtensionMembershipKeysRefusedError}'s code — `INVALID_METADATA`, for + * the reason {@link EXTENSION_DEFAULT_ROW_SCOPE_REFUSED_CODE} gives: + * `ObjectExtensionSchema` is strict and declares no `membershipKeys`, so the + * parse refuses the same body at authoring. ⛔ Not a new code. The condition is + * named by the error's `name`. + */ +export const EXTENSION_MEMBERSHIP_KEYS_REFUSED_CODE = 'INVALID_METADATA' as const; + +/** + * [#25234 ruling A, refusal 10] An `extend` contribution carried + * `membershipKeys`. A membership key is declared by the package that owns the + * roster object whose rows define it (`ObjectExtensionSchema`'s guidance): the + * key decides which records a `current_user.KEY` policy lets a caller reach, + * so another package must not be able to add one to an object it does not own. + * + * Refused whatever the value and whatever the target, as + * {@link ExtensionDefaultRowScopeRefusedError} is: the fold + * ({@link mergeObjectDefinitions}) never carries the key onto the resolved + * object, so accepting it would leave a declaration its author believes the + * security plugin reads and nothing reads. A contribution that leaves the key + * alone registers exactly as before. + * + * Reachable only by a caller that skips the parse — a direct + * `registerObject(def, pkg, undefined, 'extend')` call: the manifest path + * (`ObjectQL.registerApp`) copies a closed key list off each + * `objectExtensions` entry, and the entry's schema refuses the key at + * authoring. + * + * The ADR-0112 envelope (`code` + `status: 422`), never a bare throw. + */ +class ExtensionMembershipKeysRefusedError extends Error { + readonly code = EXTENSION_MEMBERSHIP_KEYS_REFUSED_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** The object the extension targets. */ + readonly objectName: string; + /** The package whose `extend` contribution this refusal stopped. */ + readonly packageId: string | undefined; + + constructor(objectName: string, packageId: string | undefined) { + super( + `Object "${objectName}": the \`extend\` contribution from package "${packageId}" carries ` + + '`membershipKeys`. A membership key is declared only by the package that owns the roster object ' + + 'whose rows define it, and the extension merge never carries it onto the object. Remove ' + + '`membershipKeys` from the extension: declare the key on an object your own package defines, or ' + + 'ask the owning package to declare it.', + ); + this.name = 'ExtensionMembershipKeysRefusedError'; + this.objectName = objectName; + this.packageId = packageId; + } +} + /** * [#16319] The registration-door field-`type` check — the single point of * closure the ruling names. @@ -3315,6 +3370,12 @@ export class SchemaRegistry { if (ownership === 'extend' && (schema as { defaultRowScope?: unknown }).defaultRowScope !== undefined) { throw new ExtensionDefaultRowScopeRefusedError(schema.name, packageId); } + // [#25234 ruling A, refusal 10] Nor does it declare a membership key — see + // {@link ExtensionMembershipKeysRefusedError}. The same door, for the same + // reason, so a refused contribution leaves nothing behind. + if (ownership === 'extend' && (schema as { membershipKeys?: unknown }).membershipKeys !== undefined) { + throw new ExtensionMembershipKeysRefusedError(schema.name, packageId); + } // [#23065] A `tree` field that names no `reference` references the object // that declares it, and this is where that is written down for every door diff --git a/packages/plugins/plugin-security/src/explain-engine.ts b/packages/plugins/plugin-security/src/explain-engine.ts index aee792270aa..7bb1d4b21a6 100644 --- a/packages/plugins/plugin-security/src/explain-engine.ts +++ b/packages/plugins/plugin-security/src/explain-engine.ts @@ -59,6 +59,11 @@ import { superuserBypassBitForOperation } from './permission-evaluator.js'; import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js'; import { ExplainObjectNotFoundError } from './errors.js'; import { RLS_DENY_FILTER, compiledObjectDefaultRowScopeOf, compiledPolicyNameOf } from './rls-compiler.js'; +import { + describeMembershipKeyDeclaration, + membershipKeyOutcomesFor, + type MembershipKeyOutcome, +} from './object-membership-keys.js'; import { andComposeLayers } from './tenant-layer.js'; import { declaredComparisonColumns } from './declared-comparison-columns.js'; import { @@ -1199,6 +1204,86 @@ function objectDefaultRowScopeSource( ); } +/** + * [#25234 ruling A, rider 3] The permission sets that let their holders create + * or edit rows of the roster object `object`: whoever can write a roster row + * can grant the membership key it defines. Read from the security catalog, the + * definitions the resolver grants, and judged by the evaluator the object gate + * asks. `undefined` when the catalog cannot be read: the sentence then names + * the object alone. + */ +async function rosterWriteHolders(deps: ExplainEngineDeps, object: string): Promise { + try { + const catalog = securityCatalogReaderOf(deps.ql); + if (!catalog) return undefined; + const meta = await deps.getObjectSecurityMeta(object); + const holders = new Set(); + for (const entry of await catalog.list('permission')) { + const set = entry.definition as unknown as PermissionSet; + const name = (set as { name?: unknown }).name; + if (typeof name !== 'string') continue; + const writes = ['insert', 'update'].some((op) => + deps.evaluator.checkObjectPermission(op, object, [set], { isPrivate: meta.isPrivate }), + ); + if (writes) holders.add(name); + } + return [...holders].sort(); + } catch { + return undefined; + } +} + +/** + * [#25234 ruling A] The sentences explain adds where the applicable policies + * read a declared membership key: per key, its declaration (the roster object + * X, `valueField`, `userField`, the filter), what it resolved to for this + * principal or why it did not, and who can grant it (rider 3). Empty when no + * policy read one, so every other report is unchanged. + */ +async function membershipKeySource(deps: ExplainEngineDeps, outcomes: readonly MembershipKeyOutcome[]): Promise { + let out = ''; + for (const o of outcomes) { + out += ` Membership key ${describeMembershipKeyDeclaration(o)}: ` + (o.status === 'resolved' + ? `resolved to ${o.values.length} value(s) for this principal` + + (o.values.length === 0 ? ', so every policy reading it matches no rows.' : '.') + : `did not resolve (${o.reason}: ${o.detail}), so every policy reading it matches no rows.`); + if (o.object) { + const holders = await rosterWriteHolders(deps, o.object); + out += ` Whoever can write '${o.object}' can grant this key` + + (holders === undefined ? '.' : holders.length > 0 ? `: holders of [${holders.join(', ')}].` : ': no permission set grants that write.'); + } + } + return out; +} + +/** + * [#25234 ruling A] The roster row that links this record to a resolved key: + * the first of the record's values that the key's set holds, and the roster + * row the staging read found it on. "Alice reads PO-42 because her + * `srm_supplier_contact` row C-17 links her to supplier S-1." + */ +function membershipKeyLinkOf( + record: Record, + outcomes: readonly MembershipKeyOutcome[], +): { key: string; sentence: string } | undefined { + for (const o of outcomes) { + if (o.status !== 'resolved') continue; + for (const [field, raw] of Object.entries(record)) { + for (const value of Array.isArray(raw) ? raw : [raw]) { + if (typeof value !== 'string' || !o.rosterRowOf.has(value)) continue; + const row = o.rosterRowOf.get(value); + return { + key: o.key, + sentence: + ` Membership key '${o.key}': the principal's '${o.object}' row${row ? ` '${row}'` : ''} links them to ` + + `'${value}' (its '${o.valueField}'), which this record's '${field}' holds.`, + }; + } + } + } + return undefined; +} + /** * [#20431] The names of the policies in a compiled row filter whose own filter * `refuses` — the attribution the RLS write check logs for the same refusal. @@ -1992,6 +2077,12 @@ async function applyRecordAttribution( // [#24164 ruling E′] An object's default row scope is named as itself, // reached through the object's declaration rather than a held set. const [objectDefault] = objectDefaultRowScopesOf([layer1, delegatorWriteFilter].filter(Boolean) as Record[]); + // [#25234 ruling A] A predicate that reads a declared membership key is + // reached through the key: `via: 'membership:KEY'`, and the detail names + // the roster row that links this record to the principal. + const membershipKeys = await membershipKeyOutcomesFor(context, object); + const membershipLink = !excluded ? membershipKeyLinkOf(record as Record, membershipKeys) : undefined; + const membershipVia = membershipLink?.key ?? membershipKeys[0]?.key; rlsLayer.record = { outcome: excluded ? 'excluded' : 'admitted', rowFilter: recordLayer1, @@ -2005,7 +2096,13 @@ async function applyRecordAttribution( predicate: recordLayer1, effect: excluded ? 'excludes' : 'admits', } - : { kind: 'rls_policy', name: 'business_rls', predicate: recordLayer1, effect: excluded ? 'excludes' : 'admits' }, + : { + kind: 'rls_policy', + name: 'business_rls', + ...(membershipVia ? { via: `membership:${membershipVia}` } : {}), + predicate: recordLayer1, + effect: excluded ? 'excludes' : 'admits', + }, ], detail: (deny ? 'Business RLS composes to DENY ALL for this principal.' @@ -2016,7 +2113,8 @@ async function applyRecordAttribution( ? ` On this on-behalf-of ${engineOp}, the predicate is the principal's business RLS AND the delegator's ` + 'own write filter, as the by-id write composes them (ADR-0090 D10).' : '') + - (objectDefault ? objectDefaultRowScopeSource(object, [objectDefault]) : ''), + (objectDefault ? objectDefaultRowScopeSource(object, [objectDefault]) : '') + + (membershipLink?.sentence ?? ''), }; } } @@ -2707,16 +2805,20 @@ async function explainOnce(deps: ExplainEngineDeps, input: ExplainInput, asker: // [#24164 ruling E′] Where the narrowing is an object's declared default row // scope, say so: its source is the object, not a set the principal holds. const objectDefaults = objectDefaultRowScopesOf(filterParts); + // [#25234 ruling A] The declared membership keys this object's policies read, + // as the composition above staged them on this context. + const membershipKeys = await membershipKeyOutcomesFor(context, object); layers.push({ layer: 'rls', verdict: denyAll ? 'denies' : readFilter ? 'narrows' : 'not_applicable', - detail: denyAll + detail: (denyAll ? 'Row-level security composes to DENY ALL for this principal.' : readFilter ? 'Row-level security narrows the row set (see readFilter for the composed predicate)' + (delegatorFilter ? ' — intersection of agent + delegator filters (D10).' : '.') + (objectDefaults.length > 0 ? objectDefaultRowScopeSource(object, objectDefaults) : '') - : 'No RLS policy applies.', + : 'No RLS policy applies.') + + (membershipKeys.length > 0 ? await membershipKeySource(deps, membershipKeys) : ''), contributors: [], }); diff --git a/packages/plugins/plugin-security/src/object-membership-keys.test.ts b/packages/plugins/plugin-security/src/object-membership-keys.test.ts new file mode 100644 index 00000000000..c76af74e80d --- /dev/null +++ b/packages/plugins/plugin-security/src/object-membership-keys.test.ts @@ -0,0 +1,426 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25234 ruling A, PR 2] Declared membership keys (`ObjectSchema.membershipKeys`) + * staged by `stageRlsMembership` beside the registered resolver. + * + * Driven on the real `SecurityPlugin` + `ObjectQL` over a real `SqlDriver`, + * under the `isolated` posture. `qa_supplier_contact` is the roster: it + * declares `portal_supplier_ids` (the `supplier` of each ACTIVE row whose + * `user` is the caller). `qa_po` and `qa_asn` are read through a permission + * set whose only row policy is `supplier in current_user.portal_supplier_ids`, + * and that set grants NO read on the roster. Rows are written straight into + * the tables, past every scope. + * + * `u_x` (organisation X) has three roster rows: S-1 active, S-3 inactive, and + * S-2 active but stored in organisation Y. So the key is `[S-1]`: the filter + * drops S-3 and the caller's Layer 0 wall drops S-2. + * + * The pins: + * 1. The read door and the write `check` both read the key; CONTROL: without + * the declaration the same policy matches no rows (the key fails closed). + * 2. Rider 1: the caller holds no read on the roster and still resolves the + * key; the read is the platform's system read, with the same arguments + * whatever the caller's posture or audience. Rider 2: an internal member + * resolves it exactly as a portal contact does. + * 3. One read per key per request, however many objects read it; none when + * no policy reads it. + * 4. Fail closed: no roster row (`[]`), more than the bound, a key two + * objects declare, a filter that does not compile, and a key the + * registered resolver also owns (resolved from neither, with an error at + * boot naming both owners). + * 5. Explain names the key, the roster object, its fields, filter and size, + * and who can grant it (rider 3); a record-grained rule reads + * `via: 'membership:portal_supplier_ids'` and names the roster row. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { PermissionSetSchema, MEMBERSHIP_KEY_MAX_VALUES, type PermissionSet } from '@objectstack/spec/security'; +import { RLS_MEMBERSHIP_RESOLVER_SERVICE } from '@objectstack/spec/contracts'; +import { SecurityPlugin } from './security-plugin.js'; +import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { RLSCompiler } from './rls-compiler.js'; +import { membershipKeyReadsOf, stageDeclaredMembershipKeys, type MembershipKeyStagingHost } from './object-membership-keys.js'; + +const SYS = { context: { isSystem: true } } as never; +const KEY = 'portal_supplier_ids'; +const DECLARATION = { userField: 'user', valueField: 'supplier', filter: 'active == true', label: 'Portal suppliers' }; + +const authored = (set: Record): PermissionSet => PermissionSetSchema.parse(set) as PermissionSet; + +/** The portal set: POs and ASNs of the caller's suppliers, and NO read on the roster. */ +const PORTAL = authored({ + name: 'qa_portal', + label: 'Portal', + objects: { qa_po: { allowRead: true, allowCreate: true }, qa_asn: { allowRead: true } }, + rowLevelSecurity: [ + { name: 'qa_po_my_suppliers', object: 'qa_po', operation: 'all', using: `supplier in current_user.${KEY}` }, + { name: 'qa_asn_my_suppliers', object: 'qa_asn', operation: 'select', using: `supplier in current_user.${KEY}` }, + ], +}); +/** Reads POs with a policy that reads no key: the lazy control. */ +const PLAIN_READER = authored({ + name: 'qa_plain_reader', + label: 'Plain reader', + objects: { qa_po: { allowRead: true } }, + rowLevelSecurity: [{ name: 'qa_po_north', object: 'qa_po', operation: 'select', using: "region == 'north'" }], +}); +/** Writes the roster: whoever holds it can grant the key (rider 3). */ +const ROSTER_ADMIN = authored({ + name: 'qa_roster_admin', + label: 'Roster admin', + objects: { qa_supplier_contact: { allowRead: true, allowCreate: true, allowEdit: true } }, +}); +const AUTHORED_SETS = [PORTAL, PLAIN_READER, ROSTER_ADMIN]; + +const principal = (permissions: string[], extra: Record = {}) => ({ + userId: 'u_x', + tenantId: 'org_x', + positions: [] as string[], + permissions, + posture: 'MEMBER', + org_user_ids: ['u_x'], + email: 'u_x@example.test', + ...extra, +}); + +const roster = (membershipKeys?: Record, name = 'qa_supplier_contact') => ({ + name, + label: name, + fields: { + name: { name: 'name', type: 'text' }, + user: { name: 'user', type: 'text' }, + supplier: { name: 'supplier', type: 'text' }, + active: { name: 'active', type: 'boolean' }, + }, + ...(membershipKeys ? { membershipKeys } : {}), +}); +const orders = (name: string) => ({ + name, + label: name, + fields: { + name: { name: 'name', type: 'text' }, + supplier: { name: 'supplier', type: 'text' }, + region: { name: 'region', type: 'text' }, + }, +}); + +type Table = (name: string) => { insert(rows: Array>): Promise }; +const engines: ObjectQL[] = []; +afterEach(async () => { + for (const engine of engines.splice(0)) { + try { await engine.destroy(); } catch { /* noop */ } + } +}); + +interface Shape { + /** The roster's `membershipKeys`; absent → the roster declares none. */ + keys?: Record; + /** A second roster object, declaring these keys too. */ + secondRosterKeys?: Record; + /** A registered code resolver. */ + resolver?: { keys: string[]; resolve: () => Promise> }; + /** Roster rows to add for `u_x` in organisation X, beyond the three. */ + extraRosterRows?: number; +} + +async function boot(shape: Shape) { + 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.object-membership-keys', + name: 'Object membership keys', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + roster(shape.keys), + ...(shape.secondRosterKeys ? [roster(shape.secondRosterKeys, 'qa_buyer_contact')] : []), + orders('qa_po'), + orders('qa_asn'), + ], + } as never); + await engine.syncSchemas(); + for (const o of [SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + + const sets = [...defaultPermissionSets, ...AUTHORED_SETS]; + const services: Record = { + 'org-scoping': { name: 'com.objectstack.org-scoping' }, + tenancy: { posture: 'isolated' }, + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async (_t: string, name: string) => engine.getSchema(name) ?? null, list: async () => sets }, + ...(shape.resolver ? { [RLS_MEMBERSHIP_RESOLVER_SERVICE]: shape.resolver } : {}), + }; + let security: any; + const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }; + const ctx = { + logger, + 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, fallbackPermissionSet: null }); + await plugin.init(ctx as never); + await plugin.start(ctx as never); + vi.spyOn((engine as unknown as { logger: { warn: () => void } }).logger, 'warn').mockImplementation(() => undefined); + + const table = (driver as unknown as { knex: Table }).knex; + await table('qa_supplier_contact').insert([ + { id: 'c_17', name: 'C-17', user: 'u_x', supplier: 'S-1', active: true, organization_id: 'org_x' }, + { id: 'c_21', name: 'C-21', user: 'u_x', supplier: 'S-3', active: false, organization_id: 'org_x' }, + { id: 'c_30', name: 'C-30', user: 'u_x', supplier: 'S-2', active: true, organization_id: 'org_y' }, + { id: 'c_40', name: 'C-40', user: 'u_y', supplier: 'S-2', active: true, organization_id: 'org_x' }, + ]); + const extra = Array.from({ length: shape.extraRosterRows ?? 0 }, (_, i) => ({ + id: `c_extra_${i}`, name: `extra ${i}`, user: 'u_x', supplier: `S-x${i}`, active: true, organization_id: 'org_x', + })); + // SQLite caps one compound insert, so the bound's worth of rows goes in slices. + for (let i = 0; i < extra.length; i += 200) await table('qa_supplier_contact').insert(extra.slice(i, i + 200)); + for (const object of ['qa_po', 'qa_asn']) { + await table(object).insert( + ['S-1', 'S-2', 'S-3'].map((supplier) => ({ + id: `${object}:${supplier}`, name: `${object} ${supplier}`, supplier, region: 'north', organization_id: 'org_x', + })), + ); + } + + const ids = async (object: string, context: object): Promise => { + const rows = (await engine.find(object, { context: { ...context } } as never)) as Array<{ id: string }>; + return rows.map((r) => r.id).sort(); + }; + /** The roster reads staging issued: `find` calls on the roster object under a system context. */ + const findSpy = vi.spyOn(engine, 'find'); + const rosterReads = () => + findSpy.mock.calls.filter(([object, opts]) => object === 'qa_supplier_contact' && (opts as any)?.context?.isSystem === true); + return { engine, security, logger, ids, rosterReads, table }; +} + +describe('[#25234 A] the read door and the write check read a declared key', () => { + it('a portal contact reads only the POs of its active suppliers inside its organisation', async () => { + const { ids } = await boot({ keys: { [KEY]: DECLARATION } }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual(['qa_po:S-1']); + }); + + it('CONTROL: without the declaration the same policy matches no rows (the key fails closed)', async () => { + const { ids } = await boot({}); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual([]); + }); + + it('the insert `check` reads the key too: a PO for a roster supplier is written, any other is refused', async () => { + const { engine } = await boot({ keys: { [KEY]: DECLARATION } }); + const writer = { context: principal([PORTAL.name]) } as never; + await engine.insert('qa_po', { id: 'po_new_1', name: 'new', supplier: 'S-1', region: 'south' }, writer); + const refused = await engine + .insert('qa_po', { id: 'po_new_2', name: 'new', supplier: 'S-2', region: 'south' }, writer) + .then(() => null, (e: { code?: string; status?: number }) => e); + expect({ code: refused?.code, status: refused?.status }).toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + const written = (await engine.find('qa_po', { where: { name: 'new' } }, SYS)) as Array<{ id: string }>; + expect(written.map((r) => r.id)).toEqual(['po_new_1']); + }); +}); + +describe('[#25234 A] riders 1 and 2: the platform\'s own system read, audience-neutral', () => { + it('the caller cannot read the roster, and still resolves the key from it', async () => { + const { ids, engine } = await boot({ keys: { [KEY]: DECLARATION } }); + const portal = principal([PORTAL.name]); + const ownRead = await engine + .find('qa_supplier_contact', { context: { ...portal } } as never) + .then(() => null, (e: { code?: string }) => e); + expect(ownRead?.code).toBe('PERMISSION_DENIED'); + expect(await ids('qa_po', portal)).toEqual(['qa_po:S-1']); + }); + + it('an internal member resolves the key exactly as an external contact does', async () => { + const { ids } = await boot({ keys: { [KEY]: DECLARATION } }); + expect(await ids('qa_po', principal([PORTAL.name], { posture: 'MEMBER' }))).toEqual(['qa_po:S-1']); + expect(await ids('qa_po', principal([PORTAL.name], { audience: 'external' }))).toEqual(['qa_po:S-1']); + expect(await ids('qa_po', principal([PORTAL.name], { posture: 'TENANT_ADMIN' }))).toEqual(['qa_po:S-1']); + }); + + it('the roster read is the same system read whatever the caller\'s posture or audience', async () => { + const reads: unknown[] = []; + const host = stubHost({ find: async (object, opts) => { reads.push({ object, opts }); return [] as unknown[]; } }); + const policy = { name: 'p', object: 'qa_po', operation: 'select', using: `supplier in current_user.${KEY}` } as never; + for (const extra of [ + { posture: 'MEMBER' }, + { posture: 'EXTERNAL', audience: 'external' }, + { posture: 'PLATFORM_ADMIN', permissions: ['admin_full_access'] }, + { posture: 'TENANT_ADMIN', audience: 'internal' }, + ]) { + await stageDeclaredMembershipKeys(host, { userId: 'u_x', tenantId: 'org_x', ...extra }, [policy], 'qa_po'); + } + expect(reads).toHaveLength(4); + for (const read of reads) expect(read).toEqual(reads[0]); + expect(reads[0]).toEqual({ + object: 'qa_supplier_contact', + opts: { + where: { $and: [{ user: 'u_x' }, { active: true }, { organization_id: 'org_x' }] }, + fields: ['id', 'supplier'], + limit: MEMBERSHIP_KEY_MAX_VALUES + 1, + context: { isSystem: true }, + }, + }); + }); +}); + +describe('[#25234 A] per key, lazily, once per request', () => { + it('one roster read for a request that reads the key on two objects', async () => { + const { engine, rosterReads } = await boot({ keys: { [KEY]: DECLARATION } }); + const context = principal([PORTAL.name]); + const po = (await engine.find('qa_po', { context } as never)) as Array<{ id: string }>; + const asn = (await engine.find('qa_asn', { context } as never)) as Array<{ id: string }>; + expect(po.map((r) => r.id)).toEqual(['qa_po:S-1']); + expect(asn.map((r) => r.id)).toEqual(['qa_asn:S-1']); + expect(rosterReads()).toHaveLength(1); + }); + + it('no roster read for a request whose policies read no key', async () => { + const { ids, rosterReads } = await boot({ keys: { [KEY]: DECLARATION } }); + expect(await ids('qa_po', principal([PLAIN_READER.name]))).toHaveLength(3); + expect(rosterReads()).toHaveLength(0); + }); + + it('reads the key names a predicate reads, through the SQL bridge, and never a kernel name', () => { + expect(membershipKeyReadsOf(`supplier in current_user.${KEY} || owner == current_user.id`)).toEqual([KEY]); + expect(membershipKeyReadsOf(`supplier IN (current_user.${KEY})`)).toEqual([KEY]); + expect(membershipKeyReadsOf(`supplier in current_user["${KEY}"]`)).toEqual([KEY]); + expect(membershipKeyReadsOf(`name == "current_user.${KEY}"`)).toEqual([]); + expect(membershipKeyReadsOf('id in current_user.org_user_ids')).toEqual([]); + }); +}); + +describe('[#25234 A] every miss fails closed', () => { + it('a caller with no roster row is staged an empty set and reads nothing', async () => { + const { ids } = await boot({ keys: { [KEY]: DECLARATION } }); + expect(await ids('qa_po', principal([PORTAL.name], { userId: 'u_z' }))).toEqual([]); + }); + + it('more roster rows than the bound leaves the key unresolved', async () => { + const { ids, logger } = await boot({ keys: { [KEY]: DECLARATION }, extraRosterRows: MEMBERSHIP_KEY_MAX_VALUES }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual([]); + expect(logger.warn.mock.calls.map(([m]) => String(m)).some((m) => m.includes(`'${KEY}'`) && m.includes('bound'))).toBe(true); + }); + + it('CONTROL: exactly the bound resolves', async () => { + const { ids } = await boot({ keys: { [KEY]: DECLARATION }, extraRosterRows: MEMBERSHIP_KEY_MAX_VALUES - 1 }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual(['qa_po:S-1']); + }); + + it('a filter that reads `current_user.*` does not compile for the roster, so the key is unresolved', async () => { + const { ids } = await boot({ keys: { [KEY]: { ...DECLARATION, filter: 'user == current_user.id' } } }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual([]); + }); + + it('a filter naming a column the roster lacks is refused, never read vacuously', async () => { + const { ids } = await boot({ keys: { [KEY]: { ...DECLARATION, filter: 'nope != "x"' } } }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual([]); + }); + + it('a key two objects declare resolves from neither', async () => { + const { ids, logger } = await boot({ keys: { [KEY]: DECLARATION }, secondRosterKeys: { [KEY]: DECLARATION } }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual([]); + const errors = logger.error.mock.calls.map(([m]) => String(m)); + expect(errors.some((m) => m.includes(`'${KEY}'`) && m.includes('qa_supplier_contact') && m.includes('qa_buyer_contact'))).toBe(true); + }); + + it('a key the registered resolver also owns resolves from neither, and the boot names both owners', async () => { + const resolver = { keys: [KEY], resolve: vi.fn(async () => ({ [KEY]: ['S-1', 'S-2', 'S-3'] })) }; + const { ids, logger } = await boot({ keys: { [KEY]: DECLARATION }, resolver }); + const bootErrors = logger.error.mock.calls.map(([m]) => String(m)); + expect(bootErrors.some((m) => m.includes(`'${KEY}'`) && m.includes("'qa_supplier_contact'") && m.includes('rls-membership-resolver'))).toBe(true); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual([]); + expect(resolver.resolve).toHaveBeenCalled(); + }); + + it('CONTROL: the resolver alone still supplies a key no object declares', async () => { + const resolver = { keys: [KEY], resolve: vi.fn(async () => ({ [KEY]: ['S-2'] })) }; + const { ids, logger } = await boot({ resolver }); + expect(await ids('qa_po', principal([PORTAL.name]))).toEqual(['qa_po:S-2']); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); + +describe('[#25234 A] explain names the key, the roster and who can grant it', () => { + type RlsLayer = { layer: string; verdict: string; detail: string; record?: any }; + 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('object-level: the key, X, F, U, the filter, the size and the roster\'s writers', async () => { + const { security } = await boot({ keys: { [KEY]: DECLARATION } }); + const layer = rlsLayer(await security.explain({ object: 'qa_po', operation: 'read' }, principal([PORTAL.name]))); + expect(layer.verdict).toBe('narrows'); + for (const subject of [`'${KEY}' (Portal suppliers)`, "'qa_supplier_contact'", "'supplier'", "'user'", "'active == true'", 'resolved to 1 value(s)', 'qa_roster_admin']) { + expect(layer.detail).toContain(subject); + } + expect(layer.detail).toContain("Whoever can write 'qa_supplier_contact' can grant this key"); + expect(layer.detail).not.toContain('qa_portal'); + }); + + it('object-level: a key that resolved to no value, or did not resolve, says so', async () => { + const { security } = await boot({ keys: { [KEY]: DECLARATION } }); + const empty = rlsLayer(await security.explain({ object: 'qa_po', operation: 'read' }, principal([PORTAL.name], { userId: 'u_z' }))); + expect(empty.detail).toContain('resolved to 0 value(s) for this principal, so every policy reading it matches no rows'); + const refused = await boot({ keys: { [KEY]: { ...DECLARATION, filter: 'user == current_user.id' } } }); + const layer = rlsLayer(await refused.security.explain({ object: 'qa_po', operation: 'read' }, principal([PORTAL.name]))); + expect(layer.detail).toContain('did not resolve (filter-refused'); + }); + + it('record-grained: `via: membership:KEY`, and the roster row that links the record', async () => { + const { security } = await boot({ keys: { [KEY]: DECLARATION } }); + const layer = rlsLayer( + await security.explain({ object: 'qa_po', operation: 'read', recordId: 'qa_po:S-1' }, principal([PORTAL.name])), + ); + expect(layer.record.rules).toEqual([ + expect.objectContaining({ kind: 'rls_policy', name: 'business_rls', via: `membership:${KEY}`, effect: 'admits' }), + ]); + expect(layer.record.detail).toContain("'qa_supplier_contact' row 'c_17'"); + expect(layer.record.detail).toContain("'S-1'"); + }); + + it('CONTROL: a policy that reads no key is explained as before', async () => { + const { security } = await boot({ keys: { [KEY]: DECLARATION } }); + const layer = rlsLayer(await security.explain({ object: 'qa_po', operation: 'read' }, principal([PLAIN_READER.name]))); + expect(layer.detail).not.toContain('Membership key'); + const record = rlsLayer( + await security.explain({ object: 'qa_po', operation: 'read', recordId: 'qa_po:S-1' }, principal([PLAIN_READER.name])), + ); + expect(record.record.rules[0].via).toBeUndefined(); + }); +}); + +/** A staging host over a stub engine whose registry declares the key on the roster. */ +function stubHost(engine: { find: (object: string, opts: unknown) => Promise }): MembershipKeyStagingHost { + const rosterFields = new Set(['id', 'name', 'user', 'supplier', 'active', 'organization_id']); + return { + logger: { warn: vi.fn(), error: vi.fn() }, + ql: { + find: engine.find, + registry: { getAllObjects: () => [{ name: 'qa_supplier_contact', membershipKeys: { [KEY]: DECLARATION } }] }, + }, + metadata: {}, + rlsCompiler: new RLSCompiler(), + rlsMembershipResolver: null, + tenancyPosture: 'isolated', + tenancyDisabledCache: new Map(), + datetimeFieldNamesCache: new Map(), + numberComparandFieldsCache: new Map(), + getObjectFieldNames: async () => rosterFields, + getObjectSecurityMeta: async () => ({ isPrivate: false, tenancyDisabled: false, isBetterAuthManaged: false, tenantAnchorIsPhantom: false }), + }; +} diff --git a/packages/plugins/plugin-security/src/object-membership-keys.ts b/packages/plugins/plugin-security/src/object-membership-keys.ts new file mode 100644 index 00000000000..d66ef3625fa --- /dev/null +++ b/packages/plugins/plugin-security/src/object-membership-keys.ts @@ -0,0 +1,537 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25234 ruling A] Declared membership keys (`ObjectSchema.membershipKeys`): + * the declarative second source of `context.rlsMembership`, staged beside the + * registered `rls-membership-resolver` (ADR-0105 D11) by + * `stageRlsMembership`, the one producer of the bag. + * + * A key `KEY` declared on the roster object X as `{ userField: U, valueField: + * F, filter }` makes `current_user.KEY` the distinct values of F on the rows of + * X whose U is the caller and on which `filter` holds, inside the caller's + * Layer 0 wall for X. + * + * ## Which keys, and when + * + * Lazily, per key: from the policies about to compile, take the + * `current_user.*` names their `using` and `check` read (parsed once per + * predicate by the platform's CEL front end, after the same SQL bridge the + * compiler applies), and stage each one that an object in the ObjectQL + * registry declares and this request has not staged yet. A key no applicable + * policy reads is never resolved; a request that meets none costs nothing. + * The memo is the execution context, the carrier the resolver's memo already + * uses: one request that reads a key on five objects issues one read. + * + * ## The read (rider 1 of the ruling) + * + * The platform's own Layer 0 system read, independent of the caller's audience + * and posture: X where U equals the caller's user id and `filter` holds, as + * system context, with the caller's Layer 0 wall for X AND-ed in and no Layer + * 1, projected to F (and `id`, which explain names), limited to + * {@link MEMBERSHIP_KEY_MAX_VALUES} + 1 rows. The caller need not read X: a + * portal contact who cannot see the roster still resolves the key, and X's own + * row-level security, which may read the very key this read feeds, is not + * consulted (reading X under the caller's RLS is exactly that cycle). The + * wall is the caller's own, with no platform-admin crossing, so a roster row + * in another organisation never counts. Keys are audience-neutral (rider 2): + * an internal caller resolves a key exactly as an external one does. + * + * ## Fail closed + * + * A key whose read returns no row is staged as `[]`. Every other miss leaves + * the key unstaged: more than the bound, a read that throws, a `filter` the + * compiler refuses for X, no signed-in user, a tenant wall that denies, a key + * two objects declare, and a key the registered resolver also owns (one + * namespace: it resolves from neither source, and the boot and the request + * each log an error naming both owners). A key that is staged as `[]` or left + * unstaged takes the compiler's existing path: every policy reading it drops + * with its existing WARN and, alone, fails closed to `RLS_DENY_FILTER`. + * + * A key already in the bag that the resolver did not put there was supplied + * with the context, and is left as it is, as the resolver leaves it. + * + * ## Explain + * + * Each staging outcome is kept, per context and per object that read it, for + * the explainer (`explain-engine.ts`): the `rls` layer's `detail` names the + * key, X, F, U, the filter and the size or why the key did not resolve, and + * who can grant it (whoever can write X, rider 3); a record-grained rule reads + * `via: 'membership:KEY'` and names the roster row that linked the record. + */ + +import type { RowLevelSecurityPolicy } from '@objectstack/spec/security'; +import { MEMBERSHIP_KEY_MAX_VALUES } from '@objectstack/spec/security'; +import { RESERVED_RLS_MEMBERSHIP_KEYS, type IRlsMembershipResolver } from '@objectstack/spec/contracts'; +import { parseCelToAst, sqlPredicateToCel } from '@objectstack/formula'; +import { RLS_DENY_FILTER, type RLSCompiler, type RlsFieldGuard } from './rls-compiler.js'; +import { + computeTenantLayer0Verdict, + tenantLayer0FilterOf, + tenantLayer0InputOf, + type TenantLayer0Facts, +} from './tenant-layer.js'; + +/** One declaration of a key, as the registry's resolved object carries it. */ +export interface DeclaredMembershipKey { + key: string; + /** The roster object X: the object that declares the key. */ + object: string; + userField: string; + valueField: string; + filter?: string; + label?: string; + description?: string; +} + +/** Why a declared key did not resolve. Each one fails closed. */ +export type MembershipKeyMissReason = + | 'no-user' + | 'tenant-wall-denies' + | 'filter-refused' + | 'over-bound' + | 'read-failed' + | 'declared-twice' + | 'resolver-collision'; + +/** What staging did with one declared key on one request. */ +export type MembershipKeyOutcome = + | (DeclaredMembershipKey & { + status: 'resolved'; + /** The distinct values staged, in read order. */ + values: string[]; + /** For each value, the id of the first roster row that carried it. */ + rosterRowOf: ReadonlyMap; + }) + | (Omit & + Partial> & { + status: 'unresolved'; + reason: MembershipKeyMissReason; + /** Every object that declares the key (for `declared-twice`). */ + declaredOn?: string[]; + detail: string; + }); + +/** The members of the security plugin the declared-key read uses. */ +export interface MembershipKeyStagingHost { + readonly logger: { warn?: (message: string, meta?: Record) => void; error?: (message: string) => void } | undefined; + readonly ql: any; + readonly metadata: any; + readonly rlsCompiler: Pick; + readonly rlsMembershipResolver: IRlsMembershipResolver | null; + readonly tenancyPosture: TenantLayer0Facts['tenancyPosture']; + readonly tenancyDisabledCache: ReadonlyMap; + readonly datetimeFieldNamesCache: ReadonlyMap>; + readonly numberComparandFieldsCache: ReadonlyMap>; + getObjectFieldNames(metadata: any, objectName: string, ql?: any): Promise | null>; + getObjectSecurityMeta(object: string): Promise; +} + +interface StagingState { + /** Keys the registered resolver put in the bag on this request. */ + resolverStaged: Set; + /** Per declared key: the staging, memoized for the request. */ + declared: Map>; + /** Per object: the declared keys its policies read on this request. */ + readBy: Map>; +} + +/** Per execution context: the request scope the resolver's memo already uses. */ +const STATE = new WeakMap(); + +function stateOf(context: object): StagingState { + let state = STATE.get(context); + if (!state) { + state = { resolverStaged: new Set(), declared: new Map(), readBy: new Map() }; + STATE.set(context, state); + } + return state; +} + +/** Record the keys the registered resolver staged on `context` (the collision screen reads it). */ +export function noteResolverStagedKeys(context: object, keys: Iterable): void { + const state = stateOf(context); + for (const key of keys) state.resolverStaged.add(key); +} + +// ── Which keys a predicate reads ─────────────────────────────────────────── + +/** Parsed once per predicate text: the reads are a pure function of it. */ +const KEYS_OF_PREDICATE = new Map(); +const KEYS_OF_PREDICATE_MAX = 2000; + +/** + * The `current_user.KEY` names a predicate reads, through the platform's CEL + * front end after the compiler's own SQL bridge (`sqlPredicateToCel`). A + * predicate that does not parse reads nothing here; the compiler refuses it + * on its own path. Reserved kernel names are left out: no declaration may own + * one. + */ +export function membershipKeyReadsOf(predicate: string): readonly string[] { + const cached = KEYS_OF_PREDICATE.get(predicate); + if (cached) return cached; + const out = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + return; + } + if (node === null || typeof node !== 'object') return; + const { op, args } = node as { op?: unknown; args?: unknown }; + if (Array.isArray(args) && args.length === 2) { + const [operand, member] = args as [unknown, unknown]; + const rootIsCurrentUser = + operand !== null && + typeof operand === 'object' && + (operand as { op?: unknown }).op === 'id' && + (operand as { args?: unknown }).args === 'current_user'; + if (rootIsCurrentUser) { + if ((op === '.' || op === '?.') && typeof member === 'string') out.add(member); + if ( + (op === '[]' || op === '[?]') && + member !== null && + typeof member === 'object' && + (member as { op?: unknown }).op === 'value' && + typeof (member as { args?: unknown }).args === 'string' + ) { + out.add((member as { args: string }).args); + } + } + } + walk(args); + }; + try { + walk(parseCelToAst(sqlPredicateToCel(predicate))); + } catch { + // Not parseable: the compiler refuses it on its own path. + } + const keys = [...out].filter((k) => !RESERVED_RLS_MEMBERSHIP_KEYS.includes(k)); + if (KEYS_OF_PREDICATE.size >= KEYS_OF_PREDICATE_MAX) KEYS_OF_PREDICATE.clear(); + KEYS_OF_PREDICATE.set(predicate, keys); + return keys; +} + +/** The keys a set of policies reads, in either clause: a `using` and its `check` twin stage the same keys. */ +function keysReadBy(policies: readonly RowLevelSecurityPolicy[]): Set { + const keys = new Set(); + for (const policy of policies) { + for (const clause of [policy.using, (policy as { check?: unknown }).check]) { + if (typeof clause === 'string' && clause !== '') for (const k of membershipKeyReadsOf(clause)) keys.add(k); + } + } + return keys; +} + +// ── Who declares a key ───────────────────────────────────────────────────── + +/** + * Every declaration of `key` among the ObjectQL registry's resolved objects, + * the schema the engine compiles every find against. Asked only for a key a + * policy about to compile reads and this request has not staged. + */ +export function declarationsOf(ql: any, key: string): DeclaredMembershipKey[] { + let objects: unknown; + try { + objects = ql?.registry?.getAllObjects?.(); + } catch { + return []; + } + if (!Array.isArray(objects)) return []; + const out: DeclaredMembershipKey[] = []; + for (const object of objects) { + const name = (object as { name?: unknown })?.name; + const keys = (object as { membershipKeys?: unknown })?.membershipKeys; + if (typeof name !== 'string' || keys === null || typeof keys !== 'object' || Array.isArray(keys)) continue; + const entry = (keys as Record)[key]; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue; + const { userField, valueField, filter, label, description } = entry as Record; + if (typeof userField !== 'string' || typeof valueField !== 'string') continue; + out.push({ + key, + object: name, + userField, + valueField, + ...(typeof filter === 'string' && filter.trim() !== '' ? { filter } : {}), + ...(typeof label === 'string' ? { label } : {}), + ...(typeof description === 'string' ? { description } : {}), + }); + } + return out; +} + +/** + * The keys both sources claim: declared on an object in the registry AND + * owned by the registered resolver. Each resolves from neither; the boot + * logs them (`SecurityPlugin.start`), and the request that meets one logs it + * again. + */ +export function resolverCollisionsOf(ql: any, resolver: IRlsMembershipResolver | null): Array<{ key: string; objects: string[] }> { + if (!resolver || !Array.isArray(resolver.keys)) return []; + const out: Array<{ key: string; objects: string[] }> = []; + for (const key of new Set(resolver.keys)) { + const objects = declarationsOf(ql, key).map((d) => d.object); + if (objects.length > 0) out.push({ key, objects }); + } + return out; +} + +/** The sentence both the boot and the request log for a key both sources claim. */ +export function resolverCollisionMessage(key: string, objects: readonly string[]): string { + return ( + `[security/#25234] membership key '${key}' is declared by ${objects.map((o) => `'${o}'`).join(', ')} ` + + '(`membershipKeys`) AND owned by the registered rls-membership-resolver: one key has one source, so it ' + + 'resolves from NEITHER and every policy reading `current_user.' + key + '` matches no rows. Rename one of them.' + ); +} + +// ── Staging ──────────────────────────────────────────────────────────────── + +/** + * Stage, on `context`, every declared key the given policies read that this + * request has not staged yet. `object` names the object whose policies these + * are, for the explainer; the keys staged are the same with or without it. + */ +export async function stageDeclaredMembershipKeys( + host: MembershipKeyStagingHost, + context: any, + policies: readonly RowLevelSecurityPolicy[], + object?: string, +): Promise { + if (!context || typeof context !== 'object' || policies.length === 0) return; + const reads = keysReadBy(policies); + if (reads.size === 0) return; + const state = stateOf(context); + const pending: Array> = []; + for (const key of reads) { + let staging = state.declared.get(key); + if (!staging) { + staging = stageOneKey(host, context, state, key); + state.declared.set(key, staging); + } + pending.push(staging); + if (object) { + const seen = state.readBy.get(object) ?? new Set(); + seen.add(key); + state.readBy.set(object, seen); + } + } + await Promise.all(pending); +} + +/** The bag on `context`, created on first write. */ +function bagOf(context: any): Record { + if (!context.rlsMembership || typeof context.rlsMembership !== 'object') context.rlsMembership = {}; + return context.rlsMembership as Record; +} + +/** Errors already logged, per logger: a collision is a deployment fault, not a per-request one. */ +const LOGGED_ERRORS = new WeakMap>(); + +function errorOnce(host: MembershipKeyStagingHost, id: string, message: string, meta: Record): void { + const logger = host.logger; + if (!logger) return; + const seen = LOGGED_ERRORS.get(logger) ?? new Set(); + LOGGED_ERRORS.set(logger, seen); + if (seen.has(id)) return; + seen.add(id); + if (logger.error) logger.error(message); + else logger.warn?.(message, meta); +} + +async function stageOneKey( + host: MembershipKeyStagingHost, + context: any, + state: StagingState, + key: string, +): Promise { + const declarations = declarationsOf(host.ql, key); + // Not a declared key: the resolver's, the caller's, or nobody's. Nothing to stage here. + if (declarations.length === 0) return undefined; + + const bag = context.rlsMembership as Record | undefined; + // The resolver owns what it declares, and what it staged on this request. + const resolverOwns = !!host.rlsMembershipResolver?.keys?.includes(key) || state.resolverStaged.has(key); + if (resolverOwns) { + // One namespace: the key resolves from neither source. A value the resolver + // already staged is withdrawn before any policy compiles against it. + if (bag && state.resolverStaged.has(key)) delete bag[key]; + const objects = declarations.map((d) => d.object); + errorOnce(host, `collision:${key}`, resolverCollisionMessage(key, objects), { key, objects }); + return { + ...declarations[0], + status: 'unresolved', + reason: 'resolver-collision', + declaredOn: objects, + detail: `the registered rls-membership-resolver owns '${key}' too, so it resolves from neither source`, + }; + } + // Supplied with the context (not by the resolver, which does not own it): left as it is. + if (bag && bag[key] !== undefined) return undefined; + + if (declarations.length > 1) { + const objects = declarations.map((d) => d.object); + errorOnce( + host, + `twice:${key}`, + `[security/#25234] membership key '${key}' is declared on ${objects.map((o) => `'${o}'`).join(', ')}: one key ` + + 'name is one request-wide set, so it resolves from none of them and every policy reading it matches no rows.', + { key, objects }, + ); + return { + key, + status: 'unresolved', + reason: 'declared-twice', + declaredOn: objects, + detail: `'${key}' is declared on ${objects.length} objects (${objects.join(', ')}), so it resolves from none`, + }; + } + + const declaration = declarations[0]; + const outcome = await readDeclaredKey(host, context, declaration); + if (outcome.status === 'resolved') { + bagOf(context)[key] = outcome.values; + } else { + host.logger?.warn?.( + `[security/#25234] membership key '${key}' (declared on '${declaration.object}') did not resolve for this ` + + `request: ${outcome.detail}. Every policy reading it fails closed.`, + { key, object: declaration.object, reason: outcome.reason, userId: context.userId }, + ); + } + return outcome; +} + +/** The system read of one declaration for the caller `context` names. */ +async function readDeclaredKey( + host: MembershipKeyStagingHost, + context: any, + d: DeclaredMembershipKey, +): Promise { + const miss = (reason: MembershipKeyMissReason, detail: string): MembershipKeyOutcome => ({ + ...d, + status: 'unresolved', + reason, + detail, + }); + const userId = context.userId; + if (typeof userId !== 'string' || userId === '') { + return miss('no-user', 'the request carries no signed-in user to read the roster for'); + } + + let objectFields: Set | null; + let meta: TenantLayer0Facts['meta']; + try { + objectFields = await host.getObjectFieldNames(host.metadata, d.object, host.ql); + meta = await host.getObjectSecurityMeta(d.object); + } catch (e) { + return miss('read-failed', `the roster object's schema could not be read (${(e as Error)?.message ?? String(e)})`); + } + + // Layer 0: the caller's own wall for X, and no crossing. `isPlatformAdmin` + // is false whatever the caller holds: the read is the platform's, not the + // caller's, so neither the caller's posture nor its audience changes which + // roster rows count (rider 1). + const layer0 = tenantLayer0FilterOf( + computeTenantLayer0Verdict( + tenantLayer0InputOf({ + tenancyPosture: host.tenancyPosture, + context, + meta, + objectFields, + tenancyDisabled: host.tenancyDisabledCache.get(d.object) === true || meta.tenancyDisabled, + isPlatformAdmin: false, + }), + ), + ); + if (layer0 === RLS_DENY_FILTER) { + return miss('tenant-wall-denies', 'the tenant wall for the roster object denies this caller (no organization scope)'); + } + + // The filter, judged by the compiler that judges every policy, against X's + // columns, with no caller variables: a filter reading `current_user.*`, a + // shape that does not lower, or a column X lacks is refused. + let filter: Record | null = null; + if (d.filter) { + const compiled = host.rlsCompiler.compileFilter( + [{ name: `membershipKeys.${d.key}.filter`, object: d.object, operation: 'select', using: d.filter } as RowLevelSecurityPolicy], + {} as never, + 'using', + objectFields + ? { + declared: objectFields, + datetime: host.datetimeFieldNamesCache.get(d.object), + number: host.numberComparandFieldsCache.get(d.object), + } + : undefined, + ); + if (compiled === null || compiled === RLS_DENY_FILTER) { + return miss('filter-refused', `the key's filter '${d.filter}' does not compile for '${d.object}'`); + } + filter = compiled; + } + + const where = [{ [d.userField]: userId }, filter, layer0].filter(Boolean) as Record[]; + let rows: unknown; + try { + rows = await host.ql.find(d.object, { + where: where.length === 1 ? where[0] : { $and: where }, + fields: ['id', d.valueField], + limit: MEMBERSHIP_KEY_MAX_VALUES + 1, + // The wall is the `where` above, the caller's Layer 0 as the read door + // composes it; the system context carries no organization of its own, + // so under `group` every organization the wall admits counts. + context: { isSystem: true }, + }); + } catch (e) { + return miss('read-failed', `the roster read failed (${(e as Error)?.message ?? String(e)})`); + } + const list: unknown[] = Array.isArray(rows) ? rows : Array.isArray((rows as { value?: unknown })?.value) ? (rows as { value: unknown[] }).value : []; + if (list.length > MEMBERSHIP_KEY_MAX_VALUES) { + return miss('over-bound', `more than ${MEMBERSHIP_KEY_MAX_VALUES} roster rows match, over the per-key bound`); + } + const values: string[] = []; + const rosterRowOf = new Map(); + for (const row of list) { + const raw = (row as Record | null)?.[d.valueField]; + const id = (row as Record | null)?.id; + for (const value of Array.isArray(raw) ? raw : [raw]) { + if (typeof value !== 'string' || value === '' || rosterRowOf.has(value)) continue; + rosterRowOf.set(value, typeof id === 'string' ? id : undefined); + values.push(value); + } + } + if (values.length > MEMBERSHIP_KEY_MAX_VALUES) { + return miss('over-bound', `the roster rows carry more than ${MEMBERSHIP_KEY_MAX_VALUES} values, over the per-key bound`); + } + return { ...d, status: 'resolved', values, rosterRowOf }; +} + +// ── Explain ──────────────────────────────────────────────────────────────── + +/** + * The staging outcomes of the declared keys `object`'s policies read on + * `context`, in the order first read. Empty when none was read (and on a + * context staging never saw), so a report about an object whose policies read + * no declared key is unchanged. + */ +export async function membershipKeyOutcomesFor(context: unknown, object: string): Promise { + if (!context || typeof context !== 'object') return []; + const state = STATE.get(context); + const keys = state?.readBy.get(object); + if (!state || !keys) return []; + const out: MembershipKeyOutcome[] = []; + for (const key of keys) { + const outcome = await state.declared.get(key); + if (outcome) out.push(outcome); + } + return out; +} + +/** How a key is declared, in words: its label and description, X, F, U and the filter. */ +export function describeMembershipKeyDeclaration(o: MembershipKeyOutcome): string { + const about = [o.label, o.description].filter((text): text is string => typeof text === 'string' && text.trim() !== ''); + const named = `'${o.key}'${about.length > 0 ? ` (${about.join(' — ')})` : ''}`; + if (!o.object) return named; + return ( + `${named}, declared on '${o.object}' as the '${o.valueField}' values of its rows whose '${o.userField}' is the user` + + (o.filter ? ` and on which '${o.filter}' holds` : '') + ); +} diff --git a/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts b/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts index f492cb6a881..088204af8a5 100644 --- a/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts +++ b/packages/plugins/plugin-security/src/security-plugin-rls-filter.ts @@ -14,7 +14,10 @@ * owner crossing asks (#12974, #13147). * - `computeWriteCheckFilter`, the ADR-0058 D4 post-image `check` compile. * - `stageRlsMembership`, the ADR-0105 D11 membership staging both compiles - * ask before they run. + * ask before they run. Since #25234 (ruling A) it also stages the declared + * membership keys the policies about to compile read + * (`object-membership-keys.ts`), so its resolver half is + * `stageResolverMembership`. * * A MOVE, not a redesign. Each function below is the body of the * `SecurityPlugin` method of the same name as it stood, re-indented one level @@ -78,14 +81,16 @@ import type { SecurityPlugin } from './security-plugin.js'; import { isCarriedPosture, writeCheckPolicies } from './security-plugin.js'; -import type { PermissionSet, TenantLayer0Verdict } from '@objectstack/spec/security'; +import type { PermissionSet, RowLevelSecurityPolicy, TenantLayer0Verdict } from '@objectstack/spec/security'; import { RESERVED_RLS_MEMBERSHIP_KEYS } from '@objectstack/spec/contracts'; import { isConfiguredPlatformAdminEmail, resolvePlatformAdminEmails } from '@objectstack/core'; import { RLS_DENY_FILTER, policyDeclaresClause } from './rls-compiler.js'; import { isPlatformOwnershipFloorPolicy } from './platform-ownership-policies.js'; +import { noteResolverStagedKeys, stageDeclaredMembershipKeys } from './object-membership-keys.js'; import { computeTenantLayer0Verdict, tenantLayer0FilterOf, + tenantLayer0InputOf, type TenantLayer0Input, } from './tenant-layer.js'; import { @@ -419,6 +424,9 @@ export async function computeLayeredRlsFilter( return false; }) : allRlsPolicies; + // [#25234 ruling A] The declared membership keys these policies read, + // staged before they compile. The resolver's half ran above. + await this.stageRlsMembership(context, compilable, object); layer1 = this.rlsCompiler.compileFilter( compilable, context, @@ -445,35 +453,17 @@ export async function computeLayeredRlsFilter( // global object correctly contributes nothing (ADR-0095 delta c). // [#15813] The verdict is computed ONCE; the filter injected below is its // projection. Recording the verdict costs no second computation. - const tenantLayer0Input: TenantLayer0Input = { + // The input is gathered by `tenantLayer0InputOf` (`tenant-layer.ts`), the + // one builder every reader of the wall shares, so the declared + // membership-key read walls its roster read with this same input. + const tenantLayer0Input: TenantLayer0Input = tenantLayer0InputOf({ tenancyPosture: this.tenancyPosture, - organizationId: context?.tenantId, - // [ADR-0105 D2] The `group` wall's predicate. Resolved by - // `resolveAuthzContext` and carried on the context — never re-derived here. - accessibleOrgIds: context?.accessible_org_ids, - // [#7835] A FEDERATED object's `organization_id` may be the registry's - // injected anchor rather than a remote column — present in the field set, - // absent from the store the query actually runs against (the platform - // issues no DDL for `external` objects). Answering "yes, tenant object" - // there AND-composes `organization_id = ` onto a federated read, - // where it isolates nothing and — on SQLite — degrades to a constant-false - // string comparison: 0 rows, no error, HTTP 200. This is the - // plugin-security sibling of #7738 / PR #7833, which withheld - // `DriverOptions.tenantId` for the same objects one layer down; that fix - // cannot reach a `where` predicate composed into the AST. - // - // Only the PLATFORM's anchor is discounted (provenance, per - // `federated-phantom-anchors.ts`): a federated object that DECLARES a real - // remote `organization_id` keeps its wall, and every local object is - // untouched. `undefined` (schema unresolvable) still means "assume tenant - // object" — the fail-toward-isolation default is unchanged. - objectHasOrgIdField: objectFields - ? objectFields.has('organization_id') && !meta.tenantAnchorIsPhantom - : undefined, + context, + meta, + objectFields, tenancyDisabled, - posturePermitsCrossTenant: posturePermits, isPlatformAdmin, - }; + }); const tenantLayer0Verdict = computeTenantLayer0Verdict(tenantLayer0Input); const layer0 = tenantLayer0FilterOf(tenantLayer0Verdict); // [ADR-0095 D3 · ADR-0123 D2 · #24821] Did the platform operator's crossing, @@ -645,7 +635,9 @@ export async function computeWriteCheckFilter( // check should always have had — it relaxes nothing: no resolver, a // throwing resolver or an unresolved key still drop the policy and still // fail closed, on this path as on the read path. - await this.stageRlsMembership(context); + // [#25234 ruling A] With the policies, so the declared membership keys a + // `check` reads are staged too — the same keys its `using` twin stages. + await this.stageRlsMembership(context, withCheck, object); // [#17042] The write face's field-existence net — which this path had NONE // of. `computeWriteCheckFilter` compiled `check` clauses with no column // check at all, so a phantom column in a negated position @@ -676,10 +668,26 @@ export async function computeWriteCheckFilter( /** The body of `SecurityPlugin#stageRlsMembership`; the method's docblock there is its contract. */ export async function stageRlsMembership( + this: Pick, + context: any, + policies?: readonly RowLevelSecurityPolicy[], + object?: string, +): Promise { + if (!context || typeof context !== 'object') return; + await stageResolverMembership.call(this, context); + // [#25234 ruling A] The declarative second source: the keys these policies + // read that an object declares (`object-membership-keys.ts`), per key and + // memoized per request. After the resolver, so a key both sources claim is + // withdrawn before anything compiles against it. + if (policies && policies.length > 0) await stageDeclaredMembershipKeys(this, context, policies, object); +} + +/** The registered resolver's half of {@link stageRlsMembership}: once per request. */ +async function stageResolverMembership( this: Pick, context: any, ): Promise { - if (!this.rlsMembershipResolver || !context || typeof context !== 'object') return; + if (!this.rlsMembershipResolver) return; // One resolution per request: the same context object is threaded through // every object the operation touches. if (context.__rlsMembershipStaged) return; @@ -704,6 +712,7 @@ export async function stageRlsMembership( } const bag: Record = { ...(context.rlsMembership ?? {}) }; + const staged: string[] = []; for (const [key, value] of Object.entries(resolved)) { if (RESERVED_RLS_MEMBERSHIP_KEYS.includes(key)) { this.logger.warn?.( @@ -715,7 +724,13 @@ export async function stageRlsMembership( if (!Array.isArray(value)) continue; // An already-staged key wins: a caller-supplied bag is closer to the // request than a generic resolver. - if (bag[key] === undefined) bag[key] = value.filter((v) => typeof v === 'string'); + if (bag[key] === undefined) { + bag[key] = value.filter((v) => typeof v === 'string'); + staged.push(key); + } } + // [#25234] Which keys the resolver supplied, so a key a declaration claims + // too can be withdrawn from the bag (one namespace, neither source wins). + noteResolverStagedKeys(context, staged); if (Object.keys(bag).length > 0) context.rlsMembership = bag; } diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 437dd5953e0..04fc9a07f37 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -174,6 +174,7 @@ import { stageRlsMembership, type RlsFilterHost, } from './security-plugin-rls-filter.js'; +import { resolverCollisionMessage, resolverCollisionsOf } from './object-membership-keys.js'; // [#25223 cut 8] The permission-set resolution for an execution context: the // resolution itself, the deactivated folded-set drop, the position-name fold // report and the memo's key; the methods of the same names below hand each @@ -2165,6 +2166,13 @@ export class SecurityPlugin implements Plugin { ctx.logger.info('[security] rls-membership-resolver registered', { keys: this.rlsMembershipResolver.keys, }); + // [#25234 ruling A] One namespace: a key the resolver owns that an object + // also declares (`membershipKeys`) resolves from neither source. Said at + // boot, naming both owners, for the objects registered by now; a request + // that meets a collision registered later says it again. + for (const { key, objects } of resolverCollisionsOf(this.ql, this.rlsMembershipResolver)) { + ctx.logger.error(resolverCollisionMessage(key, objects)); + } } if (this.orgScopingEnabled) { @@ -7395,16 +7403,42 @@ export class SecurityPlugin implements Plugin { * clause it sits in; with the write-side call missing, a `check` reading a * resolver key resolved only when the request happened to read first * (commit 1d73d45c1). + * + * [#25234 ruling A] Handed the policies about to compile (and their + * object), it also stages the DECLARED membership keys they read + * (`ObjectSchema.membershipKeys`, see `object-membership-keys.ts`): per key, + * lazily, memoized per request, each through the platform's own Layer 0 + * system read of the roster object, bounded at `MEMBERSHIP_KEY_MAX_VALUES` + * with every miss failing closed. A key both the resolver and a declaration + * claim resolves from neither. Called with the context alone, it stages the + * resolver's keys only, as before. */ - private stageRlsMembership(context: any): Promise { + private stageRlsMembership( + context: any, + policies?: readonly RowLevelSecurityPolicy[], + object?: string, + ): Promise { // [#25223 cut 7] The body moved to `security-plugin-rls-filter.ts` (see `RlsFilterHost`). const readLogger = () => this.logger; + const readMetadata = () => this.metadata; + const readQl = () => this.ql; + const readRlsCompiler = () => this.rlsCompiler; const readRlsMembershipResolver = () => this.rlsMembershipResolver; - const host: Pick = { + const readTenancyPosture = () => this.tenancyPosture; + const host: Pick = { + datetimeFieldNamesCache: this.datetimeFieldNamesCache, + getObjectFieldNames: (...a) => this.getObjectFieldNames(...a), + getObjectSecurityMeta: (...a) => this.getObjectSecurityMeta(...a), get logger() { return readLogger(); }, + get metadata() { return readMetadata(); }, + numberComparandFieldsCache: this.numberComparandFieldsCache, + get ql() { return readQl(); }, + get rlsCompiler() { return readRlsCompiler(); }, get rlsMembershipResolver() { return readRlsMembershipResolver(); }, + tenancyDisabledCache: this.tenancyDisabledCache, + get tenancyPosture() { return readTenancyPosture(); }, }; - return stageRlsMembership.call(host, context); + return stageRlsMembership.call(host, context, policies, object); } private async computeWriteTenantCheckFilter( diff --git a/packages/plugins/plugin-security/src/tenant-layer.ts b/packages/plugins/plugin-security/src/tenant-layer.ts index 2963caa49c1..764864e80c3 100644 --- a/packages/plugins/plugin-security/src/tenant-layer.ts +++ b/packages/plugins/plugin-security/src/tenant-layer.ts @@ -63,6 +63,61 @@ export function tenantLayer0FilterOf(verdict: TenantLayer0Verdict): Record | null; + /** The object opted out of tenancy (the plugin's cache or the posture's own flag). */ + tenancyDisabled: boolean; + /** A true `PLATFORM_ADMIN` holding the superuser bit (the exemption's own fact). */ + isPlatformAdmin: boolean; +} + +/** {@link TenantLayer0Facts} as the input {@link computeTenantLayer0Verdict} takes. */ +export function tenantLayer0InputOf(facts: TenantLayer0Facts): TenantLayer0Input { + const { meta, objectFields, context } = facts; + return { + tenancyPosture: facts.tenancyPosture, + organizationId: context?.tenantId as string | undefined, + // [ADR-0105 D2] The `group` wall's predicate. Resolved by + // `resolveAuthzContext` and carried on the context — never re-derived here. + accessibleOrgIds: context?.accessible_org_ids as readonly string[] | undefined, + // [#7835] A FEDERATED object's `organization_id` may be the registry's + // injected anchor rather than a remote column — present in the field set, + // absent from the store the query actually runs against (the platform + // issues no DDL for `external` objects). Answering "yes, tenant object" + // there AND-composes `organization_id = ` onto a federated read, + // where it isolates nothing and — on SQLite — degrades to a constant-false + // string comparison: 0 rows, no error, HTTP 200. This is the + // plugin-security sibling of #7738 / PR #7833, which withheld + // `DriverOptions.tenantId` for the same objects one layer down; that fix + // cannot reach a `where` predicate composed into the AST. + // + // Only the PLATFORM's anchor is discounted (provenance, per + // `federated-phantom-anchors.ts`): a federated object that DECLARES a real + // remote `organization_id` keeps its wall, and every local object is + // untouched. `undefined` (schema unresolvable) still means "assume tenant + // object" — the fail-toward-isolation default is unchanged. + objectHasOrgIdField: objectFields + ? objectFields.has('organization_id') && !meta.tenantAnchorIsPhantom + : undefined, + tenancyDisabled: facts.tenancyDisabled, + posturePermitsCrossTenant: meta.isPrivate || meta.tenancyDisabled || meta.isBetterAuthManaged, + isPlatformAdmin: facts.isPlatformAdmin, + }; +} + /** * Compute the Layer 0 (tenant) filter to AND onto a read/write. * diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index c036c72fdc6..7ae22bae634 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -215,42 +215,40 @@ } }, "membershipKeys": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-23", - "evidence": "packages/spec/src/data/object.zod.ts#ObjectSchemaBase (declares the key, optional, typed as the membership-key map); packages/spec/src/security/rls.zod.ts#MembershipKeySchema (the closed entry shape: `userField`, `valueField`, optional `filter`, `label`, `description`; `valueField` equal to `userField` refused); packages/spec/src/security/rls.zod.ts#MembershipKeysSchema (the map; a key name that is not snake_case or is one of the kernel context values in `RESERVED_RLS_MEMBERSHIP_KEYS` refused); packages/spec/src/stack.zod.ts#duplicateMembershipKeyFindings (one key declared on two objects refused by `defineStack` and `composeStacks`)", - "authorWarn": true, - "authorHint": "Not applied yet: no server reads `membershipKeys` in this release, so a policy reading `current_user.KEY` matches no rows. Until the security plugin stages the key, scope rows with positions or a registered membership resolver.", - "note": "PLANNED, the reader pending: #25234 ruling A (6355640929), PR 1 of 3, the spec half. The one reader is to be plugin-security's request staging, `stageRlsMembership` (packages/plugins/plugin-security/src/security-plugin.ts), gaining this declarative second source beside the registered `rls-membership-resolver` service: per key, lazily, once per request, as the platform's own Layer 0 system read independent of the caller's audience and posture, bounded at 1,000 values with overflow failing closed (PR 2). Measured before the key existed: `git grep membershipKeys` over the whole tree at origin/main ab510f42e7 returns zero hits (control: `defaultRowScope` under packages/plugins hits), so this declaration adds no reader and a policy reading a declared key fails closed exactly as an unstaged key does today. The field-type, filter and policy-comparison refusals (ruled refusals 4 to 7 and 11 to 13) are the authoring rules' (PR 3). Drilled into `children` because `check:liveness` refuses an undeclared container inheritance; each child is `planned` for the same reason as the container, and only the container carries `authorWarn`, so authoring the key warns once. Flip to `live` on the commit that lands PR 2, citing `stageRlsMembership` as the evidence and explain's `via: 'membership:KEY'` as the second read point, and drop `authorWarn` / `authorHint` in that same edit: a warned `live` row reds this gate. Flip the children with the container.", + "evidence": "packages/plugins/plugin-security/src/security-plugin-rls-filter.ts#stageRlsMembership (stages, beside the registered `rls-membership-resolver`, every declared key the policies about to compile read, on the read door's Layer 1 and the write `check` alike); packages/plugins/plugin-security/src/object-membership-keys.ts#stageDeclaredMembershipKeys (finds the declaring object in the ObjectQL registry, memoizes per key per request, refuses a key two objects declare or the resolver also owns); packages/plugins/plugin-security/src/object-membership-keys.ts#readDeclaredKey (the platform's own Layer 0 system read of the roster: `userField` equal to the caller, the compiled `filter`, the caller's tenant wall, projected to `valueField`, bounded at `MEMBERSHIP_KEY_MAX_VALUES`, every miss fail closed); packages/plugins/plugin-security/src/explain-engine.ts#membershipKeySource (the second read point: explain names the key, the roster object, its fields, the filter, the size or the miss, and the roster's writers; a record-grained rule reads `via: 'membership:KEY'`)", + "note": "LIVE since #25234 PR 2 (ruling A, 6355640929), flipped from `planned` with `authorWarn` / `authorHint` dropped in the same edit, as the row's note asked. The reader is plugin-security's `stageRlsMembership`: a key is resolved only when a policy about to compile reads `current_user.KEY`, once per request, by a system read of the declaring object that is independent of the caller's audience and posture (rider 1: a caller with no read on the roster still resolves its key) and walled by the caller's own Layer 0 with no platform-admin crossing. No roster row stages `[]`; more than 1,000 values, a failed read, a `filter` that does not compile for the object, a key two objects declare and a key the registered resolver also owns leave the key unstaged, so every policy reading it fails closed through the compiler's existing path. Pinned through the real SecurityPlugin + ObjectQL over SqlDriver in packages/plugins/plugin-security/src/object-membership-keys.test.ts, and ablated there: removing the declared staging, the write-side staging, the system read or the Layer 0 term each turns its pins red. An `extend` contribution carrying the key is refused at registration (packages/objectql/src/registry.ts#ExtensionMembershipKeysRefusedError). The field-type, filter and policy-comparison refusals at publish (ruled refusals 4 to 7 and 11 to 13) are the authoring rules' (PR 3). Drilled into `children` because `check:liveness` refuses an undeclared container inheritance; the children are flipped with the container.", "children": { "userField": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-23", - "evidence": "packages/spec/src/security/rls.zod.ts#MembershipKeySchema (declares `userField`: the column holding the roster row's user, a snake_case column name)", - "note": "Planned with its container: no server reads `membershipKeys` until plugin-security's `stageRlsMembership` does. Flip with the container." + "evidence": "packages/plugins/plugin-security/src/object-membership-keys.ts#readDeclaredKey (the roster read's equality anchor: `userField` equal to the caller's user id)", + "note": "Live with its container (#25234 PR 2): read where the container is staged and explained." }, "valueField": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-23", - "evidence": "packages/spec/src/security/rls.zod.ts#MembershipKeySchema (declares `valueField`: the column whose values form the set, a snake_case column name, refused when equal to `userField`)", - "note": "Planned with its container: no server reads `membershipKeys` until plugin-security's `stageRlsMembership` does. Flip with the container." + "evidence": "packages/plugins/plugin-security/src/object-membership-keys.ts#readDeclaredKey (the column projected; its distinct non-empty values, multi-valued ones flattened, are the staged set)", + "note": "Live with its container (#25234 PR 2): read where the container is staged and explained." }, "filter": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-23", - "evidence": "packages/spec/src/security/rls.zod.ts#MembershipKeySchema (declares `filter`: the optional CEL narrowing over this object's own columns)", - "note": "Planned with its container: no server reads `membershipKeys` until plugin-security's `stageRlsMembership` does. Flip with the container." + "evidence": "packages/plugins/plugin-security/src/object-membership-keys.ts#readDeclaredKey (compiled by `RLSCompiler.compileFilter` against the roster's columns with no caller variables and AND-ed into the read; a filter it refuses leaves the key unresolved)", + "note": "Live with its container (#25234 PR 2): read where the container is staged and explained." }, "label": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-23", - "evidence": "packages/spec/src/security/rls.zod.ts#MembershipKeySchema (declares `label`: the human-readable name of the set)", - "note": "Planned with its container: no server reads `membershipKeys` until plugin-security's `stageRlsMembership` does. Flip with the container." + "evidence": "packages/plugins/plugin-security/src/object-membership-keys.ts#describeMembershipKeyDeclaration (explain names the key with its `label`)", + "note": "Live with its container (#25234 PR 2): read where the container is staged and explained." }, "description": { - "status": "planned", + "status": "live", "verifiedAt": "2026-10-23", - "evidence": "packages/spec/src/security/rls.zod.ts#MembershipKeySchema (declares `description`: what the set holds and who it is for)", - "note": "Planned with its container: no server reads `membershipKeys` until plugin-security's `stageRlsMembership` does. Flip with the container." + "evidence": "packages/plugins/plugin-security/src/object-membership-keys.ts#describeMembershipKeyDeclaration (explain names the key with its `description`)", + "note": "Live with its container (#25234 PR 2): read where the container is staged and explained." } } }, diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 8053da9179d..5ab04973d56 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 61 | 0 | 0 | 1 | 5 | 67 | +| `object` | 66 | 0 | 0 | 1 | 0 | 67 | diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 15e40e3aa83..829e7108518 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -3078,15 +3078,15 @@ const ObjectSchemaBase = strictObject( * `supplier in current_user.portal_supplier_ids` with no stamp on every * record. * - * The describe states the CONTRACT the reader is to meet (Prime Directive - * #10): the one reader is to be plugin-security's request staging + * The describe states the CONTRACT the reader meets (Prime Directive #10): + * the one reader is plugin-security's request staging * (`stageRlsMembership`, beside the registered code resolver), resolving a * key lazily, once per request, as the platform's own system read inside * the caller's organization wall and never through the caller's audience, * posture or row policies; at most `MEMBERSHIP_KEY_MAX_VALUES` (1,000) values, * and a larger set, a failed read or an unstaged key fails closed. Keys are - * audience-neutral. Until that lands no server reads the key; the liveness - * row is `planned` with an author warning. + * audience-neutral. A key the registered resolver also owns resolves from + * neither. The liveness row is `live` (#25234 PR 2). * * One key on two objects is refused by `defineStack` and `composeStacks`; * the field-type, filter and policy-comparison refusals are the authoring @@ -3100,12 +3100,11 @@ const ObjectSchemaBase = strictObject( + 'userField: \'user\', valueField: \'supplier\', filter: \'active == true\' }`). Declare a key on the ' + 'one object whose rows define it; a key name is one request-wide name, so declaring it on two objects is ' + 'refused, and the kernel\'s own names (`id`, `organization_id`, `positions`, `org_user_ids`, ' - + '`accessible_org_ids`, `email`) are refused. The contract the server is to meet once it reads the key: ' - + 'the set is read once per request, as a system read inside the caller\'s organization and regardless of ' - + 'what the caller may read on this object; it holds at most 1,000 values, and a larger set, a failed read ' - + 'or a key nothing resolves makes every policy reading it match no rows. Not applied yet: no server reads ' - + 'this key in this release, so a policy reading `current_user.KEY` matches no rows. Until it is applied, ' - + 'scope rows with positions or a registered membership resolver.', + + '`accessible_org_ids`, `email`) are refused. The set is read once per request, only when a policy reads ' + + 'the key, as a system read inside the caller\'s organization and regardless of what the caller may read on ' + + 'this object, so the caller needs no read on it; it holds at most 1,000 values, and a larger set, a failed ' + + 'read, a `filter` that does not compile for this object, or a key a registered membership resolver also ' + + 'owns makes every policy reading it match no rows. Whoever can write this object\'s rows can grant the key.', ), /** diff --git a/packages/spec/src/security/rls.zod.ts b/packages/spec/src/security/rls.zod.ts index 7cc14922342..d5293268e07 100644 --- a/packages/spec/src/security/rls.zod.ts +++ b/packages/spec/src/security/rls.zod.ts @@ -61,8 +61,9 @@ import { strictObject } from '../shared/strict-object'; * - a **registered membership resolver** (ADR-0105 D11, the * `rls-membership-resolver` service), code an installed plugin runs; or * - a **declared membership key**: `ObjectSchema.membershipKeys` on the roster - * object whose rows define the set. Not applied yet: the security plugin - * does not read the declaration in this release. + * object whose rows define the set, which the security plugin stages + * per request beside the resolver's keys. A key both sources claim + * resolves from neither. * * The platform stages no other key. A policy reading a key neither source * supplies drops out and matches no rows (fail closed). @@ -784,8 +785,9 @@ export const DefaultRowScopeSchema = lazySchema(() => { * lowers and reads no `current_user.*`, a key a registered resolver also owns, * and how a policy compares the key. * - * Not read yet: the security plugin's request staging is the reader (the - * liveness row is `planned`). + * Read by the security plugin's request staging (`stageRlsMembership`): per + * key, once per request, as the platform's own Layer 0 system read of the + * roster object (the liveness row is `live`). */ /** From e35ae5dab2734dc1cc49332366635a8583158c0b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 19:32:31 +0000 Subject: [PATCH 2/3] fix(plugin-security): name no tracker id in the membership-key log lines Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339 --- .../plugins/plugin-security/src/object-membership-keys.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-security/src/object-membership-keys.ts b/packages/plugins/plugin-security/src/object-membership-keys.ts index d66ef3625fa..331c336bbe6 100644 --- a/packages/plugins/plugin-security/src/object-membership-keys.ts +++ b/packages/plugins/plugin-security/src/object-membership-keys.ts @@ -277,7 +277,7 @@ export function resolverCollisionsOf(ql: any, resolver: IRlsMembershipResolver | /** The sentence both the boot and the request log for a key both sources claim. */ export function resolverCollisionMessage(key: string, objects: readonly string[]): string { return ( - `[security/#25234] membership key '${key}' is declared by ${objects.map((o) => `'${o}'`).join(', ')} ` + + `[security] membership key '${key}' is declared by ${objects.map((o) => `'${o}'`).join(', ')} ` + '(`membershipKeys`) AND owned by the registered rls-membership-resolver: one key has one source, so it ' + 'resolves from NEITHER and every policy reading `current_user.' + key + '` matches no rows. Rename one of them.' ); @@ -372,7 +372,7 @@ async function stageOneKey( errorOnce( host, `twice:${key}`, - `[security/#25234] membership key '${key}' is declared on ${objects.map((o) => `'${o}'`).join(', ')}: one key ` + + `[security] membership key '${key}' is declared on ${objects.map((o) => `'${o}'`).join(', ')}: one key ` + 'name is one request-wide set, so it resolves from none of them and every policy reading it matches no rows.', { key, objects }, ); @@ -391,7 +391,7 @@ async function stageOneKey( bagOf(context)[key] = outcome.values; } else { host.logger?.warn?.( - `[security/#25234] membership key '${key}' (declared on '${declaration.object}') did not resolve for this ` + + `[security] membership key '${key}' (declared on '${declaration.object}') did not resolve for this ` + `request: ${outcome.detail}. Every policy reading it fails closed.`, { key, object: declaration.object, reason: outcome.reason, userId: context.userId }, ); From 3c309af1e8a89330c9b3a0e9662f0a6fe9b7f427 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 19:44:39 +0000 Subject: [PATCH 3/3] fix(plugin-security): the membership-key staging sink always carries warn The optional-error sink contract refuses a logger whose `error` and `warn` are both optional: an absent `error` must fall back to a `warn` that exists. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339 --- packages/plugins/plugin-security/src/object-membership-keys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-security/src/object-membership-keys.ts b/packages/plugins/plugin-security/src/object-membership-keys.ts index 331c336bbe6..71e6d356a53 100644 --- a/packages/plugins/plugin-security/src/object-membership-keys.ts +++ b/packages/plugins/plugin-security/src/object-membership-keys.ts @@ -113,7 +113,7 @@ export type MembershipKeyOutcome = /** The members of the security plugin the declared-key read uses. */ export interface MembershipKeyStagingHost { - readonly logger: { warn?: (message: string, meta?: Record) => void; error?: (message: string) => void } | undefined; + readonly logger: { warn: (message: string, meta?: Record) => void; error?: (message: string) => void } | undefined; readonly ql: any; readonly metadata: any; readonly rlsCompiler: Pick;