|
| 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