You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit e5eab06
Browse filesBrowse the repository at this point in the historyBrowse files
feat(plugin-security): a declared membership key is staged per request, beside the registered resolver (#25528)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} -->
_Requested by **Tesla** · [project
thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoJLr1A7TYy1k6cGE23eo9VQ)_
Part of #25234
Clause-②: no
Metadata-protocol: semantic
PR 2 of 3 of ruling A on #25234 (ruling 6355640929, design 6344270438,
landing plan 6370632236): the reader. `stageRlsMembership`, the one
producer of `ExecutionContext.rlsMembership`, now stages the
`ObjectSchema.membershipKeys` that PR 1 (#25513) declared, beside the
registered `rls-membership-resolver`. It also closes the objectql
registry `extend` door that PR 1's acceptance notes named. Liveness
flips `planned` to `live`. PR 3 (the authoring rules) carries `Closes#25234`.
Serial against #25233 (PR #25515 also touches plugin-security):
whichever lands second merges `main`. This head already merged `main` at
`74496c64` (which contains #25515).
## What a declared key resolves to, and when
`current_user.KEY` = the distinct values of `valueField` (F) on the rows
of the declaring roster object X whose `userField` (U) is the caller and
on which `filter` holds, inside the caller's own Layer 0 wall for X.
- **Which keys, when**
(`object-membership-keys.ts#stageDeclaredMembershipKeys`). From the
policies about to compile, take the `current_user.*` names their `using`
and `check` read. They are parsed once per predicate text by
`@objectstack/formula`'s `parseCelToAst`, after the compiler's own
`sqlPredicateToCel` bridge, and the parse is cached. A name the ObjectQL
registry declares, and this request has not staged, is resolved. A key
no policy reads is never resolved. The memo is per key on the execution
context: a request that reads the key on `qa_po` and `qa_asn` issues one
roster read (pinned).
- **Both compile sites** (`security-plugin-rls-filter.ts`). The read
door stages just before Layer 1 compiles, with the `compilable` list.
The write `check` stages with `withCheck`. A `using` and its `check`
twin therefore read the same keys. The resolver half runs first and is
unchanged (`stageResolverMembership`). It now records which keys it
staged, so a collision can withdraw them.
- **The read** (`readDeclaredKey`): X where `U == userId`, AND the
compiled `filter`, AND the caller's Layer 0 filter for X. It is
projected to `id` and F and limited to 1,001 rows, under `{ isSystem:
true }`, and Layer 1 is not consulted.
## Rider 1: Layer 0 system read, independent of audience and posture
- The read's context is `{ isSystem: true }` and nothing of the
caller's. The middleware's total `isSystem` bypass means X's own row
policies, which may read the very key this read feeds, are not
consulted. A caller with no read on X still resolves its key.
- The wall is the caller's own. `computeTenantLayer0Verdict` over
`tenantLayer0InputOf(...)` is computed with `isPlatformAdmin: false`
whatever the caller holds, so no crossing applies, and a roster row in
another organisation never counts. `tenantLayer0InputOf` is a new
builder in `tenant-layer.ts`. It holds the input literal
`computeLayeredRlsFilter` used to inline, comments included, and the
read door now builds its input through it, so the two cannot diverge.
- The system context deliberately carries no `tenantId`. With one, the
engine scoped the driver to the active organisation on its own, which
would have narrowed a `group`-posture key below the wall. The `where`
wall is therefore the only organisation scope, and the Layer 0 ablation
below proves it is load-bearing.
- **Pins:**
- Real engine: the portal set has no grant on `qa_supplier_contact`, and
its own find of the roster is refused with `PERMISSION_DENIED`, while
its PO read resolves the key.
- Unit: four callers (`MEMBER`; `EXTERNAL` with `audience: 'external'`;
`PLATFORM_ADMIN` holding `admin_full_access`; `TENANT_ADMIN`) produce
byte-identical `find` arguments, asserted in full: the `where` with the
org term, `fields`, the limit, and `context: { isSystem: true }`.
- **Rider 2:** `MEMBER`, `audience: 'external'` and `TENANT_ADMIN`
callers all read the same rows, so keys are not scoped to externals.
## Fail closed
| Case | Staged | Logged |
|:--|:--|:--|
| no roster row | `[]` (the compiler drops the policy, as for any empty
set) | no |
| more than 1,000 rows, or more than 1,000 values after flattening |
unstaged | warn |
| read throws; schema unreadable | unstaged | warn |
| `filter` refused by `RLSCompiler.compileFilter` against X's columns
with no caller variables (non-lowering shape, a column X lacks, any
`current_user.*`) | unstaged | warn, plus the compiler's own |
| tenant wall denies (no organization scope) | unstaged | warn |
| no signed-in user | unstaged | warn |
| a key two registry objects declare | unstaged | error, once per logger
|
| a key the registered resolver owns (its `keys`, or one it staged this
request) | unstaged; the resolver's value is withdrawn from the bag |
error at boot (`SecurityPlugin.start`, naming the key and every
declaring object) and once at request time |
A key already in the bag that the resolver did not put there was
supplied with the context, and it is left as is, as the resolver leaves
it.
## Explain (rider 3)
- **Object level.** The `rls` layer's `detail` gains one sentence per
key this object's policies read. It names the key with its `label` and
`description`, then X, F, U, the filter, and "resolved to N value(s)" or
"did not resolve (REASON: detail)". It then says "Whoever can write 'X'
can grant this key: holders of [...]". The holders are the security
catalog's permission sets for which
`PermissionEvaluator.checkObjectPermission` grants `insert` or `update`
on X. An object whose policies read no key is explained byte-identically
(control pinned).
- **Record level.** The `business_rls` rule reads `via:
'membership:KEY'`. The detail names the roster row that links the record
("the principal's 'qa_supplier_contact' row 'c_17' links them to 'S-1'
(its 'supplier'), which this record's 'supplier' holds"). The row id
comes from the staging read, which projects `id` beside F, so no second
read is made. `ExplainDecision` gains no field (#24451's choice).
- Not changed: an empty membership set still reads `narrows` at object
level (the existing composed-deny recognition), as for a resolver key.
## objectql: the `extend` door
`SchemaRegistry.registerObject` refuses an `ownership: 'extend'`
contribution carrying `membershipKeys`. It answers `INVALID_METADATA` /
422 with the name `ExtensionMembershipKeysRefusedError`, beside the
`defaultRowScope` refusal and before the contributor list opens.
Measured first: `mergeObjectDefinitions` copies only `fields`,
`validations`, `indexes`, `label`, `pluralLabel` and `description`, so
the extender's key registered and was then dropped in silence. The new
test is `registry-extension-membership-keys-refused.test.ts` (6). It
covers three refused shapes, including an empty map; an owner with no
key; and two controls (an extension without the key folds, and the owner
re-registers).
## Liveness and spec wording
- `liveness/object.json`: `membershipKeys` and its 5 children are
`live`, and `authorWarn` / `authorHint` are dropped in the same edit, as
the row's note asked. The evidence is `stageRlsMembership`,
`stageDeclaredMembershipKeys` and `readDeclaredKey`, with
`membershipKeySource` as the second read point. `label` and
`description` are live through `describeMembershipKeyDeclaration`, which
is why explain names them.
- The `membershipKeys` describe and TSDoc and the `rls.zod.ts` "Where a
membership key comes from" section and module note no longer say "not
applied". The accept set is unchanged. `check:generated --fix`
regenerated 4 reference pages and `state-counts/object.md`.
- **Changeset** `.changeset/25234-membership-keys-staged.md`: `minor` on
`@objectstack/plugin-security`, `patch` on `@objectstack/objectql` and
`@objectstack/spec`.
## Tests
`packages/plugins/plugin-security/src/object-membership-keys.test.ts`
has 21 tests on the real `SecurityPlugin` + `ObjectQL` over `SqlDriver`,
`isolated` posture. In the fixture, `u_x`'s roster has S-1 active, S-3
inactive, and S-2 active but in organisation Y, so the key is `[S-1]`.
- Read door, insert `check`, and a CONTROL without the declaration (zero
rows).
- Riders 1 and 2, as above.
- The memo (one read across two objects) and laziness (zero reads for a
policy that reads no key). The key extraction covers dot, index and
SQL-bridge spellings, a string literal, and kernel names.
- Fail-closed rows: no roster row, the bound (1,001 rows red, exactly
1,000 green), a `current_user` filter, a phantom-column filter, a key
declared twice, and a resolver collision with the boot error. CONTROL:
the resolver alone still supplies an undeclared key, with no error.
- Explain at object level (positive and both miss kinds), at record
level, and a CONTROL.
**Ablations, each restored byte-identically from a saved copy:**
- Declared staging removed from `stageRlsMembership`: 12 red, 9 green.
- Write-side call reverted to `stageRlsMembership(context)`: only the
insert-`check` test red.
- Roster read run under the caller's context instead of `isSystem`: 11
red, including both rider-1 pins.
- Layer 0 term dropped from the roster `where` (system context without
`tenantId`): 8 red.
- objectql refusal disabled: 4 refusal tests red, 2 controls green.
**Suites on this head's code, through `os-verify-lock.sh`:**
- `@objectstack/plugin-security`: 219 files, 4594 passed, 45 skipped.
`typecheck` (incl. the test layer) exit 0.
- `@objectstack/objectql`: 467 files, 8705 passed. `tsc --noEmit` exit
0.
- `@objectstack/spec`: 792 files, 22953 passed, 1 todo.
## Gates
`node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derives 124 families. All 124 were run one
at a time, with exit codes captured before any pipe. `--ran`
reconciliation on head `e35ae5da`: 124 derived, 124 run, 0 NOT-MEASURED,
0 unrun, all exit 0.
- One real finding, fixed in `e35ae5da`: `check:doc-authoring` refused
the tracker id in three runtime log strings. The ids are gone from the
strings.
- Four first-pass non-zero codes were environment, not findings.
`check-engine-split-ratio` exited 2 on the shallow clone and passed
after `git fetch --shallow-since=2026-07-18`. `check:skill-examples`,
`check:dual-build-cjs-loads` and `check:i18n` exited 3 (no dist) and
passed after a full workspace build.
- Those five were re-run on `e35ae5da`. The other 119 ran on `21cace53`,
which differs from `e35ae5da` only by those three string literals.
## For the seat: the Tier H skills row, re-worded for `live`
The row from report 6370454361 says "planned: no server stages it yet".
With this PR it should read:
> | `current_user.KEY` (a declared membership key) | the values of
`valueField` on the rows of the roster object that declares
`membershipKeys.KEY` whose `userField` is the caller and on which its
`filter` holds, inside the caller's organization (for `IN`; read once
per request even when the caller cannot read the roster; more than 1,000
values, a failed read, or a key a registered resolver also owns matches
no rows) |
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01M4rJHRAZDFqLYQ9dHMH339
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(plugin-security): a declared membership key is staged per request, so a policy reading `current_user.KEY` reads the roster
8
+
9
+
Clause-②: no
10
+
Metadata-protocol: semantic
11
+
12
+
`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.
13
+
14
+
-**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.
15
+
-**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.
16
+
-**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.
17
+
-**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.
18
+
-**`@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.
19
+
-**`@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`.
|**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). |
973
973
| **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. |
974
-
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | 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. |
974
+
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | 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. |
975
975
|**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. |
|**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). |
176
176
| **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. |
177
-
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | 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. |
177
+
| **membershipKeys** | `Record<string, { userField: string; valueField: string; filter?: string; label?: string; … }>` | 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. |
178
178
|**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. |
0 commit comments