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 e3470ed
Browse filesBrowse the repository at this point in the historyBrowse files
feat(security)!: resolve current_user.queues kernel-side and reserve the queues membership key (#27044)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} -->
_Requested by **Yibo Wang** · [project
thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBEyP5fgvDswGwJU9prPHL9U)_Fixes#27016
Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: `skills/objectstack-data/rules/security.md` lists the
kernel names a membership key may not take; `queues` is added there
(paid for by a deletion in the same file; the ratchet row is lowered
3693 → 3687 to sit at the measurement). The `current_user.*` placeholder
table and the queue authoring guide are #26475's.
## 维护者速读
- 改了什么:`queues` 加入 `RESERVED_RLS_MEMBERSHIP_KEYS`;`plugin-security` 从
ownership-queue 服务的 `queuesOf` 解析 `current_user.queues`(与认领闸门 `isMember`
同一份成员答案),每个请求最多一次,且只在即将编译的策略读到这个键时才解析。
- 为什么改:按 #25769 裁决 `6457475657`(甲)——一个内核侧生产者;应用 resolver 提供的 `queues`
被筛掉并失败即关闭。
- 风险与回滚:声明为收窄(席位 1 裁定):`membershipKeys` 再也不能声明名为 `queues` 的键,resolver 的
`queues` 答案被丢弃并 WARN。仓内声明/resolver 普查为 0。回滚 = revert 本 PR。
-
先看:`packages/plugins/plugin-security/src/kernel-queue-membership.ts`(新生产者)、`rls-compiler.ts`
的 `userContextOf` 一行、`security-plugin-rls-filter.ts` 的
`stageRlsMembership`、`.changeset/27016-queues-reserved-membership-key.md`。
- 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run
src/kernel-queue-membership.test.ts
src/rls-reserved-membership-keys.test.ts`,预期全绿。
- 需要你做的:无,席位落地。
## What changes
- **spec** (`packages/spec/src/contracts/rls-membership-resolver.ts`):
`queues` joins `RESERVED_RLS_MEMBERSHIP_KEYS`, with a doc comment saying
what it holds, who resolves it and why an app may not. The module
header's "What does NOT belong here" and the `keys` doc name it too.
`rls.zod.ts` (the "Where a membership key comes from" docblock) and the
`ObjectSchema.membershipKeys` `.describe()` list it among the kernel
names; the reference pages are regenerated (`gen:docs`, 4 files).
- **plugin-security**:
- New `kernel-queue-membership.ts`: `stageKernelQueues` resolves
`queuesOf(userId, tenantId)` once per request and stores it on the
context under a module-private symbol, with the principal and
organization it was resolved for; `kernelQueuesOf` answers only for that
identity. So a caller cannot forge it (`context.queues` is never read),
and a context copied for another user or organization does not carry it.
- `stageRlsMembership` stages it first, and only when a policy about to
compile reads `current_user.queues` (`policiesReadCurrentUserKey`, a
sibling of `membershipKeyReadsOf` on the same parsed-once CEL walk in
`object-membership-keys.ts`). Both compile sites (read `using`, write
`check`) already call it with their policies.
- `RLSCompiler.userContextOf` sets `queues:
kernelQueuesOf(executionContext)` beside `positions`, and
`RLSUserContext` declares it.
- `SecurityPlugin.ownershipQueueMembership()` resolves the same
`ownership-queue` service as `ownershipQueues()` (the claim gate's), for
`queuesOf`.
- The existing screens need no change: the resolver screen in
`stageResolverMembership` and the compiler merge both refuse a reserved
key by name, so with `queues` on the list an app resolver's or a bag's
`queues` is dropped and the policy reads the kernel value or fails
closed.
- **lint**: `KERNEL_KEY_RUNTIME_TYPE` gains `queues: 'array'` (the probe
already binds a key with no `ExecutionContext` field as an array). The
three `os explain` texts that list the kernel keys add `queues`; the
LISTS sentence no longer says `ExecutionContext` declares every list
key, since `queues` has no `ExecutionContext` field. The
unknown-variable `fix:` line says "Read a kernel key (…)" instead of
"Read a pre-resolved key (…)": the longer key list put it at 201
characters against its 200 bound.
- **skills**: the reserved-name list in
`skills/objectstack-data/rules/security.md`.
**ADR-0142 D3 amended in place.** The D3 sentence that named the queue
service as the registered `IRlsMembershipResolver` for `queues` (option
B's mechanism) now says what the tree does: `queues` is a reserved key
that plugin-security resolves kernel-side from the queue service, the
way it resolves `positions`, and an app resolver answering `queues` is
screened out and fails closed. It carries an inline **Amended** note
citing ruling 6457475657 (#25769, letter A). The Execution plan bullet
about the PR that already landed is left as history.
## Why
Ruling `6457475657` on #25769 (letter A): `queues` is reserved and
resolved kernel-side in plugin-security, one producer, like `positions`;
an app resolver that supplies it is screened and fails closed. Options B
(plugin-sharing's own resolver) and C (drop the key) were not chosen.
On the declaration: the card said `yes (widening)` / `none`.
Re-measured, `RESERVED_RLS_MEMBERSHIP_KEYS` is also read by
`MembershipKeyNameSchema` (`rls.zod.ts`, the refine on
`ObjectSchema.membershipKeys` key names), so the change refuses an
authored `membershipKeys.queues` at parse. Seat 1 ruled it `no
(narrowing)` / `narrowed`, with ADR-0087 `not-required
(no-migration-prescription)` and a `**The remedy.**` line, since the new
name is the author's to choose.
## Readers and producers (measured on `origin/main` 7eb613c)
- `RESERVED_RLS_MEMBERSHIP_KEYS` readers: `MembershipKeyNameSchema`
(spec), `stageResolverMembership` and `RLSCompiler.userContextOf`
(plugin-security), `membershipKeyReadsOf` (plugin-security, excludes
reserved names from declared-key staging), lint's
`PRERESOLVED_USER_KEYS`. Every one reads the list by import, so no copy
needed editing.
- Pins that count the list: spec `object-membership-keys.test.ts`
(it.each over the list, no edit), plugin-security
`rls-reserved-membership-keys.test.ts` (`CELLS` must equal the list:
`queues` cell added), lint `KERNEL_KEY_RUNTIME_TYPE` and the
explanation-text pins.
- Who moves: zero `membershipKeys` declarations named `queues` and zero
resolvers declaring `queues` in `packages`, `examples`, `qa`, `skills`
or `content`.
- `queuesOf` vs `isMember` in `plugin-sharing`'s
`OwnershipQueueService`: both answer through `specsReachUser` with the
`access` purpose (switched-off positions confer nothing on either). A
new test there pins that they agree for every user, queue and
organization of the fixture.
- plugin-sharing's own sitting-record path
(`SharingService.callerQueues`) also reads `queuesOf`; it is the owner
match's disjunct, not the RLS key, and is unchanged.
## Tests
- `plugin-security/src/kernel-queue-membership.test.ts` (12 cases,
through the whole plugin's `getReadFilter`): a member reads `{ queue: {
$in: [their queues] } }` in the active organization, and another
organization's set in another; a non-member gets the deny sentinel,
byte-identical to no queue service; the set agrees with `isMember`;
resolved once per request and not at all when no policy reads the key;
no user or a throwing service fails closed (one WARN); an app resolver
answering `queues` is dropped with the reserved-key WARN and the kernel
set stands, a non-member it tried to seat reads nothing, and with no
queue service it does not fill the gap; a `queues` field or bag entry on
the context is never read; a copied context for another user or
organization does not carry the set.
- `plugin-security/src/rls-reserved-membership-keys.test.ts`: the
`queues` cell (bag refused byte-identically on `using` and `check`,
kernel value wins).
- `plugin-sharing/src/ownership-queue.test.ts`: `queuesOf` agrees with
`isMember` (both answers occur, so not vacuous).
- Probe: `owner_id == null && queue in current_user.queues` compiles to
`{"$and":[{"owner_id":{"$null":true}},{"queue":{"$in":[…]}}]}`.
Ablation (on committed heads, restored with `git checkout HEAD`):
compiler line set to `queues: undefined` gives 5 failed (member,
organization, isMember agreement, resolver screened, `queues`
kernel-wins cell); `queues` removed from the built spec list gives 4
failed (is reserved, resolver screened, no-service gap, caller-forged
field).
## Verification
Runs on head `8660c53ef` (worktree off `origin/main` `7eb613c3`):
- Under the lock: `@objectstack/spec` 738 files / 22131 passed (1 todo);
`@objectstack/plugin-sharing` 57 files / 1279 passed;
`@objectstack/lint` 170 files / 7510 passed;
`@objectstack/plugin-security` 234 files / 5065 passed, 45 skipped;
`typecheck` for all four packages exit 0. (The first lint run found two
pins this diff moves: the unknown-variable `fix:` line at 201
characters, and the engine-judge test's copy of the explanation's key
list. Both fixed in `8660c53ef`, then lint and plugin-security re-ran
green.)
- `pnpm --filter @objectstack/spec check:generated`: only
`content/docs/references/**` was stale; regenerated with `gen:docs` and
committed.
- `dispatch-gates --commands`: 115 derived commands. 111 exit 0 on the
first sweep. `check-skills-token-ratchet` was red (+3 tokens on
`security.md`), paid by a deletion in the same file, then exit 0.
`check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n`
exited 3 (built output missing) and exit 0 after the dogfood closure
build.
- Full dogfood (owed by the narrowing arm), `@objectstack/dogfood^...`
closure build plus the whole suite under the lock: 344 files passed, 3
skipped, 1 failed (348); 2788 tests passed, 20 skipped, 2 failed. Both
failures are `packaged-action-disabled-dispatch-live.dogfood.test.ts`
(acceptance[0] and [5]/[2]), and they reproduce when that file runs
alone. Not this diff: the test reads the first `showcase_task` unsorted
(`$top=1`) and expects it not yet done, and here that row was already
`done: true` before the action ran (`before` equals `after`). Nothing in
the showcase or dogfood stacks reads `current_user.queues` (`git grep`),
so the new staging never runs there. Reported to the seat as an
out-of-scope finding.
## Human Minimum
Each item is one rule from
[CONTRIBUTING.md › Human
Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum),
which links the AGENTS.md text that states it.
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran. `pnpm
lint` is CI's repo-wide run.)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed (`gen:docs`; `check:generated` otherwise up to
date).
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01WGufzhD6Fqfyavauv2KoGA
---------
Co-authored-by: Claude <noreply@anthropic.com>
feat(security)!: `current_user.queues` is resolved by the platform from the ownership-queue service, and `queues` joins the reserved `current_user` keys (#27016)
9
+
10
+
Clause-②: no (narrowing) — `queues` joins `RESERVED_RLS_MEMBERSHIP_KEYS`, so an object's `membershipKeys` may no longer declare a key named `queues`, and a registered membership resolver's `queues` answer is screened off. Nothing is widened past what the platform itself resolves: where no app supplied the key, a policy reading `current_user.queues` matched no rows before, and now it matches the caller's own queues.
11
+
Metadata-protocol: narrowed
12
+
13
+
<!-- adr-0087: not-required (no-migration-prescription) No key, export or type is removed or renamed: one name is added to the reserved list, which makes a membership key of that name refused at parse. The replacement name is the author's to choose, so there is no mechanical rewrite to prescribe and no tombstone. -->
14
+
15
+
**BREAKING** accept-set narrowing in `@objectstack/spec` (`MembershipKeyNameSchema`, read through `ObjectSchema.membershipKeys`) and at the membership resolver screen in `@objectstack/plugin-security`.
16
+
17
+
-**Now.** A row-level security policy can read `current_user.queues`: the machine names of the ownership queues the caller belongs to in the active organization, directly or through a position, team or unit, as each queue's members define. The security plugin resolves it from the ownership-queue service's `queuesOf`, the same membership answer the claim gate's `isMember` reads, once per request and only when a policy about to compile reads the key. For example `owner_id == null && queue in current_user.queues` lets queue members read the unowned records sitting in their queues.
18
+
-**One source.** No other source can supply the key. A registered `rls-membership-resolver` that answers `queues` has that answer dropped with a WARN, a `rlsMembership` bag entry or a `queues` field on the execution context is never read, and a context copied for another user or organization does not carry the set. With no signed-in user, no ownership-queue service, or a membership read that fails, the key stays unresolved and every policy reading it matches no rows.
19
+
-**Who moves.** An object that declares `membershipKeys: { queues: … }`, or a membership resolver that lists `queues` among its keys. Census on this repository at 7eb613c3: none in `packages`, `examples`, `qa`, `skills` or `content`.
20
+
21
+
**The remedy.** Rename the membership key. `queues` is resolved by the platform; choose a name that says what your set holds.
Copy file name to clipboardExpand all lines: content/docs/references/api/metadata.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -966,7 +966,7 @@ Metadata query with filtering, sorting, and pagination
966
966
|**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). |
967
967
|**sharing**|`{ controlledByParent?: object }`| optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). |
968
968
| **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. |
969
-
| **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. |
969
+
| **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`, `queues`) 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. |
970
970
|**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. |
Copy file name to clipboardExpand all lines: content/docs/references/data/object.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -173,7 +173,7 @@ const result = ApiMethod.parse(data);
173
173
|**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). |
174
174
|**sharing**|`{ controlledByParent?: object }`| optional | Per-object sharing opt-ins that refine the org-wide default sharingModel declares. controlledByParent.ownerMayEdit: on a controlled_by_parent detail, the row's owner may update it with read access to the master (ADR-0055 §3 amendment). |
175
175
| **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. |
176
-
| **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. |
176
+
| **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`, `queues`) 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. |
177
177
|**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