Skip to content

Commit f72bc9e

Browse files
fix(core)!: org_user_ids holds every member of an organization with more than 1,000 members (#29356)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9P46GvEyAUWWadkKbQCv3hz)_ Fixes #29281 Clause-②: no (narrowing) Metadata-protocol: none Skills-docs: none — no skill or doc teaches the 1,000 bound or a size limit for the organization peer list; `content/docs/permissions/rls.mdx` describes `current_user.org_user_ids` as "User ids in the user's org", which this makes true, and the bind-parameter ceiling is the interim's, removed by the relational predicate (#29360). The claim declared `no` for an interim that reads the membership to completion. The seat's ruling (6577690137 on #29281) reads the refusal past the bound-parameter limit as a narrowing, so this PR carries `!` and the full dogfood battery. ## 维护者速读 - 改了什么:成员超过 1,000 人的组织里,普通成员在人员选择器搜索和按 id 读取用户时,重新能看到每一位同组织成员;组织外的人依旧看不到。 - 为什么改:同组织成员名单只读了前 1,000 条成员记录,之后的成员被静默隐藏(搜索为空、按 id 读返回 404),而且隐藏的是谁由随机 id 决定。 - 风险与回滚:不足 1,000 人的组织读取次数和可见范围完全不变;更大的组织每个请求多读一次完整成员名单(实测 2 万人约 45 毫秒)。属于破坏性变更的只有超过数据库绑定参数上限的超大组织(SQLite 实测 3.2 万可用、4 万报错):其成员读用户时会明确报错,而不再返回残缺名单。回滚即撤销本 PR。 - 先看:packages/core/src/security/resolve-authz-context.ts 中的 readOrganizationMembersToCompletion,以及第 5 步那一处“读满即续读”的判断。 - 两分钟验证:运行 pnpm --filter @objectstack/plugin-security exec vitest run src/sys-user-org-members-past-first-read.test.ts,在真实 SQLite 上用 1,001 名成员验证搜索、按 id 读取和组织外成员不可见。 - 需要你做的:无,席位落地。 ## What changes `sys_user`'s organization-members row scope is `id in current_user.org_user_ids`: the object's `defaultRowScope` and `member_default`'s `sys_user_org_members`. The authorization resolver builds that list per request from one `sys_member` read for the active organization, bounded at 1,000 rows. The bound used to be the list. In an organization with more members, everyone past the 1,000th `sys_member.id` was left out with no signal: a people picker's search found nothing, and a read by id answered `404`. This PR is the server-only interim the card's ruling asks for. It makes no spec change and changes no metadata. - **The first read is unchanged.** It has the same filter, the same 1,000 bound and the same leg, so an organization under the bound issues the same single read it always did and gets the same list. - **A first read that comes back at its bound is now a first page, not the membership.** This is the same reading `countPositionHolders` gives its own reads. §5 then calls `readOrganizationMembersToCompletion` (`packages/core/src/security/resolve-authz-context.ts`). It is a keyset read of the active organization's `sys_member` rows: ordered by `id`, `id > last` per page, 10,000 rows per page, columns `id` and `user_id` only. It starts over from the first id, because the first read asked for no order and its 1,000 rows need not be the lowest ids. - **Loud, never short.** A page that fails to read throws the existing `AuthzStoreUnavailableError` (`503`). So do a page that repeats an id an earlier page returned (a driver that ignored the cursor) and a full page whose rows carry no id. None of them ever answers a shorter list. - **Said once.** One `console.warn` per organization per engine names the organization, its member count and the cost: every request in that organization reads its whole membership, and each `sys_user` read binds one parameter per member. It is a `warn` and not an `error` under AGENTS.md's degradation rule, because nothing is lost: the list is complete. - `tryFind` takes an optional `orderBy`. Changeset: `@objectstack/core` patch, with a **BREAKING** banner and an ADR-0087 disposition (`not-required (no-migration-prescription)`). - **The narrowing.** In an organization whose membership passes the backend's bound-parameter limit, every `sys_user` read by its members now fails loudly at the driver (`500`) where it used to answer a list missing everyone past the 1,000th member. That is the only accept-set change; below the limit, nobody gains or loses visibility of anyone. ### Who sees whom | Organization size | Before | After | |---|---|---| | under 1,000 members | every member, from one read | the same: one read, the same list (pinned) | | exactly 1,000 | every member | the same members, and nothing logged; one extra read that comes back short, because a first read at its bound cannot tell 1,000 from more (pinned) | | over 1,000 | the first 1,000 by `sys_member.id`, with the rest silently missing | every member plus the caller | | anyone outside the organization | invisible | invisible (pinned and ablated) | Every completion page filters `organization_id = <active org>`, and the cursor is added to that filter, never in place of it. I found no path where the old bound or the new read could add someone who is not a member. This is a visibility bug that hid rows. ### Options weighed (driver-sql over better-sqlite3 in process, median of three, on a shared box) | Option | Per-request cost measured | Visible set | Verdict | |---|---|---|---| | A. Read to completion only when the first read is at its bound (chosen) | 0 extra reads under 1,000 members. Completion read: 1,001 members 1 read, 2.9 ms; 5,000: 1 read, 9.1 ms; 20,000: 3 reads, 44.5 ms; 40,000: 5 reads, 100 ms | exact | chosen | | B. Refuse loudly in the resolver when the first read is at its bound | 0 extra reads; every request by every member of an organization over 1,000 members fails | narrows to nothing | rejected: changes who sees whom | | C. Log only and keep the truncated list | 0 | still wrong | rejected by the ruling | | D. Option A with the first read's page shape (1,000 rows, every column) | 20,000 members: 21 reads, 219 ms; 40,000: 41 reads, 485 ms | exact | rejected: about 5× the cost of A's page | Every request pays the downstream `sys_user` read with the full list: | Members | `sys_user` search | read by id | bound parameters | |---|---|---|---| | 1,001 | 8.8 ms | 4.9 ms | 1,007 | | 20,000 | 57.4 ms | 39.5 ms | | | 32,000 | 83 ms | 58.9 ms | 32,006 | | 40,000 | refused: `too many SQL variables` (`500`) | | | The truncated 1,000-id list cost about 6 to 8 ms. **Does the RLS compiler already have a relational form? No.** ADR-0139's `ReadableIds` semi-join leaf is engine-internal and cannot be authored from a scope. It projects the parent's `id`, not `sys_member.user_id`, and its inner filter is the caller's read scope, not `organization_id = <tenant>`. `rls.zod.ts` §7.3.1 states that subquery-shaped needs are pre-resolved. Replacing the list therefore needs an ADR and the spec lane. The design notes are in the card's os-dev-report, and no ADR file is drafted here. ## Why The card's ruling is that the visible set must stay exactly the same: every member, and nobody else. On `main` an ordinary member of an organization with 1,001 members could not find the member whose `sys_member.id` sorts 1,001st, by search or by id. The ruling also says the pre-resolved list is to be replaced by a relational predicate, which is an ADR and spec-lane change. This PR is the interim the card asks for in the meantime: it keeps the set exact up to the backend's bind-parameter limit, fails loudly past it, and never shrinks the scope silently. ## Tests - `packages/core/src/security/resolve-authz-context.org-members-past-first-read.test.ts` (new, 8 tests). It uses a double that honours equality, `$gt`, `orderBy` and `limit`, and answers an unordered read in insertion order, which is not id order. - 1,005 members: the exact set, with no other organization's members. - 20,007 members: the first read is byte-identical, then the three exact completion reads with their cursors. - 999 members: one read, the same set, and no warning. - Exactly 1,000 members: the same set, one completion read, and no warning. - The warning is said once per organization and engine, and names the organization and the count. - Loud failures: a page that fails to read, a full page without ids, and a cursor that does not advance. - `packages/plugins/plugin-security/src/sys-user-org-members-past-first-read.test.ts` (new, 4 tests). It runs a real `ObjectQL` over a real `SqlDriver` (better-sqlite3), the real identity objects, the real `SecurityPlugin` falling back to the shipped `member_default`, and a principal from `resolveUserAuthzGrants`. - 1,001 members: the member whose `sys_member.id` sorts 1,001st is found by search and by id, and the full list is exactly the organization. - ⛔ A member of another organization stays invisible by search and by id. - 999 members: exactly one membership read and exactly the members. - The warning appears once and names the count. - **Red first, on `main` `a19759ac`.** The core file was 5 failed and 1 passed at the time; the id-less and exactly-1,000 tests were added later and were shown red by ablation. The real-driver file was 3 failed and 1 passed: the 1,001-member test and the warning test failed on the defect, and the outsider pin passed on `main` as it should. The third failure was the under-bound test, whose read counter then also matched another plugin's `sys_member` read; it was narrowed to the organization read afterwards. That test pins unchanged behaviour, so it is meant to pass on `main`. - **Ablations: each forbidden behaviour was put back with `scripts/ablation-replace.mjs`, ran red, and was restored to an empty `git diff HEAD`.** Core's `dist` was rebuilt for the real-driver leg and checked with `ablation-dist-preflight.mjs`. - The completion read drops its `organization_id` filter: core 3 red; the real-driver file 3 red, including `expected [...] to not include 'usr_outsider'`. - The cursor guard answers instead of throwing: the non-advancing-cursor test is red. - A failing page is swallowed: the failing-page test is red. - The completion read always runs: the 999 pin is red, with 2 reads. - The warning is said every time: the once test is red. - The id-less full page answers: that test is red with `expected false to be true`. - A membership of exactly 1,000 is logged: the exactly-1,000 test is red with `expected "warn" to not be called at all`. - **Forced test edits** (`read-limits-truncate` is the one batch-equivalence fixture whose fellow-org read comes back at its bound, with 1,006 rows): - `resolve-authz-context.batch-equivalence.golden.json`: `grants.org_user_ids` gains the six members the bound cut (`peer_0999` to `peer_1004`), and `queries` gains the completion read. No other golden moved. - `resolve-authz-context.batch-equivalence.test.ts`: a fifth "recorded move" paragraph in the header. `BATCHED_LEGS['read-limits-truncate']` goes from 2 to 3, because the completion read is a leg of its own. The wave-1 filter excludes that read, which exists only because wave 1 came back at its bound. - `__tests__/resolve-authz-context.batch-equivalence.testkit.ts`: a comment only. The 1,000 bound is still pinned by the recorded `queries`, and the 200 bound by `accessible_org_ids`. ## Acceptance notes - **Organizations past the backend's bound-parameter limit.** Every `sys_user` read in such an organization now fails loudly at the driver (`500`) where it used to answer a silently short `200`. On SQLite that limit was crossed between 32,000 and 40,000 members; Postgres's wire protocol caps a statement at 65,535 parameters. The seat ruled this a narrowing (6577690137), hence the `!` and the `(narrowing)` arm. The relational predicate (#29360) removes the ceiling. - **`warn`, not `error`.** The card's example named an `error` line. `warn` follows AGENTS.md's degradation rule, which reserves `error` for silent loss, and here nothing is lost. The seat ruled `warn` (6577690137), and the 10,000-row page of `id` and `user_id` too. - **Not measured on Postgres or MongoDB.** There is no Postgres server in the container, and the `mongod` download answers 403. The completion read uses only `where` equality, `$gt`, `orderBy` and `limit`. - **Same family, not touched here.** The seat filed the capped `sys_member` reads in plugin-sharing and plugin-approvals as #29362. Also noted in the report: - Every `rlsMembership` key has the same pre-resolved, bound-per-request shape. - Other readers take `org_user_ids` as a value: audit mention-recipient gate, explain engine, the engine's run-as seeding, hook wrappers and the current-user endpoints. The design report lists them. ## Verification **Final head `c1b4f69f`.** It is `6b708b8a` plus the changeset that declares the narrowing; no code changed. - Full dogfood battery: `pnpm exec turbo run build --filter='@objectstack/dogfood^...'` (exit 0, 68 tasks), then `pnpm --filter @objectstack/dogfood test` (exit 0): Test Files 392 passed and 3 skipped (395), Tests 3227 passed and 28 skipped (3255). Lock VERDICT: `command-exit 0`, held 25m43s on a shared box. - The changeset's declaration reads `{"kind":"declared","value":"no","arm":"narrowing"}` through `scripts/pm/clause2-line.mjs`. `check-adr-0087-registration --base origin/main`: exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … not-required (no-migration-prescription)". `check-changeset-no-major`, `check-empty-changeset`, `check-issue-citations`, `check:nul-bytes` and `check:pm-changeset-deadline-census` all exit 0. - The 65 commands from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (the same list as round 1) were each run again with `< /dev/null`, and all 65 exited 0. The `--ran` reconciliation reports "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)". Lock VERDICT: `command-exit 0`. - CI on `c1b4f69f`: every check run concluded success or skipped, including TypeScript Type Check, Test Core (8 shards), Lint & Repo Gates, Check Changeset and Dogfood Regression Gate. The two failures on `6b708b8a` (TypeScript Type Check, Test UI) were aggregators whose lanes were cancelled when `c1b4f69f` was pushed. <details><summary>Commands on the round-1 head</summary> Head `6b708b8a`, which includes `origin/main` at `738971d2`. Heavy runs went through `scripts/pm/os-verify-lock.sh`, and every VERDICT was `command-exit 0`. Times are from a shared box. - `pnpm turbo run build --filter='@objectstack/plugin-security^...'`: exit 0, 18 tasks. - `pnpm --filter @objectstack/core test`: exit 0, Test Files 130 passed (130), Tests 2917 passed (2917). - `pnpm --filter @objectstack/core typecheck`: exit 0, including `check:test-typecheck`. - `pnpm --filter @objectstack/plugin-security exec vitest run`: exit 0, Test Files 240 passed (240), Tests 5175 passed and 45 skipped (5220). None of the skips is in the new file. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, including `check:test-typecheck`, which covers the new test file. - Root eslint with `--no-inline-config` on the five changed TypeScript files: exit 0. - `pnpm lint` (repo-wide): exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 commands, and each ran with `< /dev/null`. - 62 exited 0 on the first pass. - Three exited 3 because a prerequisite was not met: `check-plugin-teardown-shape --self-test` (its pinned commit was not in the shallow clone), `check:dual-build-cjs-loads` and `check:i18n` (they need every package built). After fetching that commit and running `turbo run build --filter='./packages/**'` (exit 0, 78 tasks), all three exited 0. - `--ran` reconciliation: "65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED (a DERIVED zero …)". - The reproduction and cost measurements on `main` used a scratch harness that is not committed. Its numbers are in the tables above. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. (`main` was merged at `738971d2` before opening.) - [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. (Not touched.) - [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, except the six `## 维护者速读` lines, which the maintainer asks for in Chinese. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. `content/docs/permissions/rls.mdx` already describes `current_user.org_user_ids` as "User ids in the user's org", which is now true at any size, so no documentation change is needed. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmSAV6tENpyY8j1VYz5)_ <!-- signed: domain:server#3 · os-steve · session_01ADHkmSAV6tENpyY8j1VYz5 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a20dcae commit f72bc9e

7 files changed

Lines changed: 619 additions & 5 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/core': patch
3+
---
4+
5+
fix(core)!: an ordinary member of an organization with more than 1,000 members sees every fellow member in `sys_user` again, and an organization too large for the database's bound-parameter limit is refused loudly instead of served a short list
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
The claim declared `no` for an interim that reads the membership to completion; the seat's ruling (6577690137 on #29281) reads the refusal past the bound-parameter limit as a narrowing.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped, and no export, type or metadata changes: the authorization resolver reads a list it already read, to completion. No author has anything to rewrite; the one new refusal is the database's own, for an organization whose membership exceeds what one statement can bind. -->
13+
14+
**BREAKING** for organizations past the database's bound-parameter limit only: their members' `sys_user` reads, which used to answer a silently short list, now fail.
15+
16+
`sys_user`'s organization-members row scope (`id in current_user.org_user_ids`, the object's default row scope and `member_default`'s `sys_user_org_members`) reads the fellow-org peer list the request's authorization resolver builds. That list was built from one `sys_member` read bounded at 1,000 rows, so in an organization with more members everyone past the 1,000th `sys_member.id` was missing from it with no signal: a people picker or lookup searching for them found nothing, and reading them by id answered `404`. Which members vanished was arbitrary, because member ids are random.
17+
18+
- **Every member, nobody else.** When that first read comes back at its bound, the resolver now reads the active organization's membership to completion, in pages of 10,000 ordered by id, and the list holds every member plus the caller. Members of other organizations stay out.
19+
- **Smaller organizations are unchanged.** An organization under 1,000 members issues exactly the one read it always did; nothing else is read and nothing is logged. An organization of exactly 1,000 gets the same list after one more read, and nothing is logged.
20+
- **Said once.** The first time an organization's membership outgrows the first read, the server logs one `warn` naming the organization and its member count. Every request in that organization now reads its whole membership, and each `sys_user` read in it binds one parameter per member.
21+
- **Who sees the narrowing.** An organization whose membership passes the backend's bound-parameter limit: on SQLite, 32,000 members were served and 40,000 refused (`too many SQL variables`, `500`); Postgres's wire protocol caps a statement at 65,535 parameters. Its members' `sys_user` reads now fail where they used to answer a list missing everyone past the 1,000th member. The `warn` line names the organization and its count so an operator can connect the two.
22+
- **Loud, never short.** A membership page that fails to read, or a driver that does not honour the id cursor, fails the request with the existing `503` authorization-store error instead of answering a shorter list.
23+
24+
**The remedy.** None is needed below the limit. Past it there is no workaround in this release: the relational membership predicate (#29360) removes the per-member parameters altogether.

‎packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,10 @@ export const FIXTURES: Fixture[] = [
359359
// limit, 1005 peers against the 1000 limit. Truncation is OBSERVABLE here
360360
// (the double slices like a driver), so a batch that changed either limit —
361361
// or merged the two reads under one of them — moves these arrays.
362+
// [#29281] …except the peers': a fellow-org read that comes back AT its
363+
// bound is now a first page, and the membership is read to completion, so
364+
// `org_user_ids` holds all 1,006 members; the 1000 bound is still pinned
365+
// by the recorded `queries`, and the 200 one by `accessible_org_ids`.
362366
name: 'read-limits-truncate',
363367
userId: 'u_lim',
364368
opts: { tenantId: 'org_0000', seedEmail: 'lim@x.com', nowMs: T0 },

‎packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1767,7 +1767,13 @@
17671767
"peer_0995",
17681768
"peer_0996",
17691769
"peer_0997",
1770-
"peer_0998"
1770+
"peer_0998",
1771+
"peer_0999",
1772+
"peer_1000",
1773+
"peer_1001",
1774+
"peer_1002",
1775+
"peer_1003",
1776+
"peer_1004"
17711777
],
17721778
"accessible_org_ids": [
17731779
"org_0000",
@@ -1999,6 +2005,14 @@
19992005
"limit": 1000,
20002006
"isSystem": true
20012007
},
2008+
{
2009+
"object": "sys_member",
2010+
"where": {
2011+
"organization_id": "org_0000"
2012+
},
2013+
"limit": 10000,
2014+
"isSystem": true
2015+
},
20022016
{
20032017
"object": "sys_user_permission_set",
20042018
"where": {

‎packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,18 @@
8787
* it joins the activation-ledger read's wave, so the fixture still resolves in
8888
* two legs. That read is in the golden because the code genuinely issues it —
8989
* the organization-less row this fixture used to hold asked none.
90+
*
91+
* ⚠️ A fifth recorded move ([#29281], the visible set it corrects is the
92+
* defect the card reports): the fellow-org `sys_member` read that comes back
93+
* AT its 1,000-row bound is a first page, no longer the membership, and the
94+
* resolver reads the active organization's membership to completion. One
95+
* fixture's golden moved, `read-limits-truncate`, whose 1,006 rows for
96+
* `org_0000` came back at the bound: `grants.org_user_ids` gains the six
97+
* members the bound cut (`peer_0999` … `peer_1004`), `queries` gains the
98+
* completion read (`sys_member {organization_id}`, limit 10,000), and the
99+
* fixture resolves in three legs, the completion read being its own. The
100+
* first read itself, its bound and every other recorded read are unchanged,
101+
* and so is every other fixture: none comes back at the bound.
90102
*/
91103

92104
import { readFileSync } from 'node:fs';
@@ -143,7 +155,9 @@ const BATCHED_LEGS: Record<string, number> = {
143155
'ai-seat-and-email-from-sys-user': 2,
144156
'ai-seat-denied': 2,
145157
'seeded-permissions-and-email': 2,
146-
'read-limits-truncate': 2,
158+
// [#29281] Three: the fellow-org read came back at its bound, so the
159+
// membership is read to completion after wave 1, in a leg of its own.
160+
'read-limits-truncate': 3,
147161
'no-active-org': 2,
148162
};
149163

@@ -283,6 +297,9 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque
283297
// Wave 2: the activation ledger and the Default Organization read,
284298
// which are issued together after wave 1 (#10757 leg 4).
285299
.filter((c) => c.object !== 'sys_metadata_activation' && c.object !== 'sys_organization')
300+
// [#29281] …and the membership completion read (limit 10,000), which
301+
// exists only because wave 1's fellow-org read came back at its bound.
302+
.filter((c) => !(c.object === 'sys_member' && c.limit === 10000))
286303
.map((c) => c.object)
287304
.sort(),
288305
);
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29281] `org_user_ids` — the fellow-org peer list `sys_user`'s org-members
5+
* row scope reads (`id in current_user.org_user_ids`) — holds EVERY member of
6+
* the active organization, whatever its size, and nobody else.
7+
*
8+
* It is read from `sys_member` in the resolver's first leg, bounded at 1,000
9+
* rows. That bound used to be the list: a read that came back AT it was taken
10+
* as the whole membership, so the members past the 1,000th `sys_member.id`
11+
* were silently missing from every ordinary member's `sys_user` reads. Now a
12+
* first read at its bound is the signal to read the membership to completion
13+
* (ordered by id, a page at a time) and to say so once; a first read under it
14+
* is the whole membership, exactly as before, and nothing else is read.
15+
*
16+
* The double below honours the reads' `where` (equality, `$gt`), `orderBy` and
17+
* `limit`, and answers an UNORDERED read in insertion order, which here is not
18+
* id order — so a completion that resumed after the first page's last id,
19+
* trusting that page to be the id-lowest 1,000, would miss members and fail.
20+
* The real-driver twin is plugin-security's
21+
* `sys-user-org-members-past-first-read.test.ts`.
22+
*/
23+
24+
import { describe, it, expect, vi, afterEach } from 'vitest';
25+
import { resolveUserAuthzGrants } from './resolve-authz-context.js';
26+
import { isAuthzStoreUnavailableError } from './authz-store-unavailable.js';
27+
import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js';
28+
29+
const ORG = 'org_large';
30+
const ELSEWHERE = 'org_elsewhere';
31+
const ME = 'u000000';
32+
const FIRST_READ_BOUND = 1000;
33+
/** The completion read's page (`ORG_MEMBERS_PAGE_ROWS` in `resolve-authz-context.ts`). */
34+
const PAGE_ROWS = 10000;
35+
36+
interface Read { object: string; where: Record<string, unknown>; limit?: number; orderBy?: unknown }
37+
38+
function makeQl(tables: Record<string, any[]>, opts: { ignoreCursor?: boolean; failAfter?: number } = {}) {
39+
const reads: Read[] = [];
40+
const holds = (row: any, where: Record<string, unknown>) =>
41+
Object.entries(where).every(([k, v]) => {
42+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
43+
if (v && typeof v === 'object' && '$gt' in (v as any)) {
44+
return opts.ignoreCursor ? true : String(row[k]) > String((v as any).$gt);
45+
}
46+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
47+
if (v === null) return (row[k] ?? null) === null;
48+
return row[k] === v;
49+
});
50+
return Object.assign(bindCatalogFromTables({
51+
async find(object: string, q: any) {
52+
reads.push({ object, where: JSON.parse(JSON.stringify(q?.where ?? {})), limit: q?.limit, orderBy: q?.orderBy });
53+
if (opts.failAfter !== undefined && object === 'sys_member' && reads.filter((r) => r.object === 'sys_member').length > opts.failAfter) {
54+
throw new Error('connection reset');
55+
}
56+
let rows = (tables[object] ?? []).filter((r) => holds(r, q?.where ?? {}));
57+
const order = Array.isArray(q?.orderBy) ? q.orderBy[0] : undefined;
58+
if (order) {
59+
rows = [...rows].sort((a, b) => (a[order.field] < b[order.field] ? -1 : a[order.field] > b[order.field] ? 1 : 0));
60+
if (order.order === 'desc') rows.reverse();
61+
}
62+
return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows;
63+
},
64+
}, tables), { reads });
65+
}
66+
67+
/**
68+
* `n` members of `ORG` (the caller first) plus two members of `ELSEWHERE`.
69+
* Each `sys_member.id` comes from a permutation that does not follow the user
70+
* ids, and the table holds the rows in user order, so the first 1,000 rows an
71+
* unordered read returns are NOT the 1,000 lowest ids.
72+
*/
73+
function tablesOf(n: number) {
74+
const pad = (i: number) => String(i).padStart(6, '0');
75+
const sys_member = Array.from({ length: n }, (_, i) => ({
76+
id: `m${pad((i * 7919) % 1000003)}`,
77+
user_id: `u${pad(i)}`,
78+
organization_id: ORG,
79+
role: 'member',
80+
}));
81+
sys_member.push(
82+
{ id: 'm000001x', user_id: 'u_elsewhere_1', organization_id: ELSEWHERE, role: 'member' },
83+
{ id: 'mzzzzzz', user_id: 'u_elsewhere_2', organization_id: ELSEWHERE, role: 'member' },
84+
);
85+
return {
86+
sys_user: [{ id: ME, email: 'me@example.test' }],
87+
sys_member,
88+
sys_user_position: [],
89+
sys_user_permission_set: [],
90+
};
91+
}
92+
93+
const orgReads = (reads: Read[]) => reads.filter((r) => r.object === 'sys_member' && r.where.organization_id === ORG);
94+
const membersOf = (tables: ReturnType<typeof tablesOf>) =>
95+
tables.sys_member.filter((m) => m.organization_id === ORG).map((m) => m.user_id).sort();
96+
97+
afterEach(() => vi.restoreAllMocks());
98+
99+
describe('[#29281] org_user_ids holds the whole active organization', () => {
100+
it('⭐ 1,005 members: every member and the caller, and nobody from another organization', async () => {
101+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
102+
const tables = tablesOf(FIRST_READ_BOUND + 5);
103+
const grants = await resolveUserAuthzGrants(makeQl(tables), ME, { tenantId: ORG });
104+
105+
expect([...grants.org_user_ids].sort()).toEqual(membersOf(tables));
106+
expect(grants.org_user_ids).toHaveLength(FIRST_READ_BOUND + 5);
107+
expect(grants.org_user_ids).not.toContain('u_elsewhere_1');
108+
expect(grants.org_user_ids).not.toContain('u_elsewhere_2');
109+
});
110+
111+
it('a first read AT its bound reads the rest in id order, a page at a time, inside the organization', async () => {
112+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
113+
const tables = tablesOf(PAGE_ROWS * 2 + 7);
114+
const ql = makeQl(tables);
115+
const grants = await resolveUserAuthzGrants(ql, ME, { tenantId: ORG });
116+
117+
expect([...grants.org_user_ids].sort()).toEqual(membersOf(tables));
118+
const [first, ...rest] = orgReads(ql.reads);
119+
// The first leg's read is unchanged: same filter, same bound, no ordering asked.
120+
expect(first).toEqual({ object: 'sys_member', where: { organization_id: ORG }, limit: FIRST_READ_BOUND, orderBy: undefined });
121+
// The completion starts over from the first id, then resumes after each page's last id.
122+
const ids = tables.sys_member.filter((m) => m.organization_id === ORG).map((m) => m.id).sort();
123+
expect(rest).toEqual([
124+
{ object: 'sys_member', where: { organization_id: ORG }, limit: PAGE_ROWS, orderBy: [{ field: 'id', order: 'asc' }] },
125+
{ object: 'sys_member', where: { organization_id: ORG, id: { $gt: ids[PAGE_ROWS - 1] } }, limit: PAGE_ROWS, orderBy: [{ field: 'id', order: 'asc' }] },
126+
{ object: 'sys_member', where: { organization_id: ORG, id: { $gt: ids[PAGE_ROWS * 2 - 1] } }, limit: PAGE_ROWS, orderBy: [{ field: 'id', order: 'asc' }] },
127+
]);
128+
});
129+
130+
it('999 members: the one first read is the whole membership, nothing else is read and nothing is said', async () => {
131+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
132+
const tables = tablesOf(FIRST_READ_BOUND - 1);
133+
const ql = makeQl(tables);
134+
const grants = await resolveUserAuthzGrants(ql, ME, { tenantId: ORG });
135+
136+
expect([...grants.org_user_ids].sort()).toEqual(membersOf(tables));
137+
expect(orgReads(ql.reads)).toHaveLength(1);
138+
expect(warn).not.toHaveBeenCalled();
139+
});
140+
141+
it('exactly 1,000 members: the same members, one completion read that comes back short, and nothing said', async () => {
142+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
143+
const tables = tablesOf(FIRST_READ_BOUND);
144+
const ql = makeQl(tables);
145+
const grants = await resolveUserAuthzGrants(ql, ME, { tenantId: ORG });
146+
147+
expect([...grants.org_user_ids].sort()).toEqual(membersOf(tables));
148+
// The first read cannot tell 1,000 from more, so one page is read — and it is the whole membership.
149+
expect(orgReads(ql.reads)).toHaveLength(2);
150+
expect(warn).not.toHaveBeenCalled();
151+
});
152+
153+
it('a first read at its bound is said once per organization and engine, naming the organization and the count', async () => {
154+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
155+
const tables = tablesOf(FIRST_READ_BOUND + 5);
156+
const ql = makeQl(tables);
157+
await resolveUserAuthzGrants(ql, ME, { tenantId: ORG });
158+
await resolveUserAuthzGrants(ql, ME, { tenantId: ORG });
159+
160+
const lines = warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes(ORG));
161+
expect(lines).toHaveLength(1);
162+
expect(lines[0]).toContain(`${FIRST_READ_BOUND + 5}`);
163+
expect(lines[0]).toContain('org_user_ids');
164+
});
165+
166+
it('a page that fails to read fails the resolution loudly — never a shorter list', async () => {
167+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
168+
const tables = tablesOf(FIRST_READ_BOUND + 5);
169+
// The caller's own read and the first organization read succeed; the next one throws.
170+
const err = await resolveUserAuthzGrants(makeQl(tables, { failAfter: 2 }), ME, { tenantId: ORG }).catch((e) => e);
171+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
172+
expect((err as { object?: string }).object).toBe('sys_member');
173+
});
174+
175+
it('a full page whose rows carry no id fails loudly — there is no cursor to resume after', async () => {
176+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
177+
const tables = tablesOf(PAGE_ROWS + 5);
178+
for (const m of tables.sys_member) delete (m as { id?: string }).id;
179+
const err = await resolveUserAuthzGrants(makeQl(tables), ME, { tenantId: ORG }).catch((e) => e);
180+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
181+
expect((err as { object?: string }).object).toBe('sys_member');
182+
});
183+
184+
it('a page that does not advance past its cursor fails loudly — never loops, never truncates', async () => {
185+
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
186+
const tables = tablesOf(PAGE_ROWS + 5);
187+
const err = await resolveUserAuthzGrants(makeQl(tables, { ignoreCursor: true }), ME, { tenantId: ORG }).catch((e) => e);
188+
expect(isAuthzStoreUnavailableError(err)).toBe(true);
189+
expect((err as { object?: string }).object).toBe('sys_member');
190+
});
191+
});

0 commit comments

Comments
 (0)