Skip to content

Commit e3470ed

Browse 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>
1 parent 69df290 commit e3470ed

23 files changed

Lines changed: 482 additions & 29 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/plugin-security': minor
4+
'@objectstack/lint': minor
5+
'@objectstack/skills': minor
6+
---
7+
8+
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.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -966,7 +966,7 @@ Metadata query with filtering, sorting, and pagination
966966
| **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). |
967967
| **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). |
968968
| **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. |
970970
| **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. |
971971
| **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) |
972972
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |

‎content/docs/references/data/object.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const result = ApiMethod.parse(data);
173173
| **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). |
174174
| **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). |
175175
| **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. |
177177
| **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. |
178178
| **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) |
179179
| **actions** | `{ name: string; label: string \| Record<string, string>; description?: string \| Record<string, string>; objectName?: string; … }[]` | optional | Actions associated with this object (auto-populated from top-level actions via objectName) |

‎content/docs/references/security/rls.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ object-level permissions (CRUD), RLS provides record-level filtering.
5353
### Where a membership key comes from
5454

5555
The kernel resolves `current_user.id`, `organization_id`, `positions`,
56-
`org_user_ids`, `accessible_org_ids` and `email` itself. Any other
56+
`org_user_ids`, `accessible_org_ids`, `email` and `queues` (the ownership
57+
queues the caller sits in, ADR-0142 D3) itself. Any other
5758
`current_user.KEY` read with `in` is a membership set the APP supplies,
5859
through one of two sources:
5960

0 commit comments

Comments
 (0)