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..71e6d356a53 --- /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] 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] 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] 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 f801ccbca80..97c22b060a8 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -171,6 +171,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 @@ -2167,6 +2168,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) { @@ -6949,16 +6957,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`). */ /**