Skip to content

Commit e669735

Browse files
fix(plugin-security): a refused grant holder the caller cannot read is answered as a holder that does not exist (#31244)
security#1 · os-bob · session_012dn2AdqWnb3ndnEacKvJvF Clause-②: no Metadata-protocol: none Skills-docs: none — what a metadata author writes is unchanged; only the refusal entry one class of write receives changes ## 维护者速读 - 改了什么:岗位分配(`sys_user_position`)与权限集授予(`sys_user_permission_set`)的写入,若点名一个调用者自己读不到的用户,被拒时改为得到与"该用户不存在"完全相同的回答,由引擎自己的引用检查给出。 - 为什么改:安全加固——调用者从一次被拒的授予里,不应得知任何关于自己无权查看的用户的信息。 - 风险与回滚:哪些写入被拒、状态码与错误码都不变,只换掉一类拒绝条目的文案与约束字段;出厂权限下组织管理员看不到组织外的用户,所以对组织外用户的拒绝文案会从"不是该组织成员"变为"不存在该用户记录"。回滚即还原一个文件。 - 先看:`packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts`(`holderLeftToReferenceCheck` 与 `referenceCheckAsksCaller` 两段,风险在后者的放行条件)、同名测试文件末尾新增的 describe 块、`packages/core/src/security/second-object-read-exposure.pin.test.ts` 里新增的一条普查登记、`.changeset/sec-grant-holder-unreadable-answer.md`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-security exec vitest run src/grant-holder-membership-refusal.test.ts`,预期 31 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes The rule: **a refused grant holder the caller cannot read is answered as a holder that does not exist** — on `sys_user_position` and `sys_user_permission_set`, for every non-system insert, and for every update that names a different holder than the row stores. - The grant-holder membership refusal now asks the caller's own read door about a holder the write names, the same read the engine's reference check asks of a `user_id` value. A holder that door does not return is left to the engine's reference check, which answers it with its own `reference_not_found` entry in the engine's own message context — the answer every other user reference that caller names receives. The plugin keeps no second spelling of that sentence. - A holder the caller CAN read is judged on organization membership exactly as before, and a non-member keeps the membership refusal and its remedy. - The hand-off happens only where the engine's check is certain to ask: the live object's `user_id` is a non-`readonly` `lookup` / `user` reference to `sys_user`, and `sys_user` is registered. Anywhere else the membership predicate is judged in the plugin as before. - The second-object read census (`packages/core/src/security/second-object-read-exposure.pin.test.ts`) classifies the plugin as a caller of the reference-target arbiter that serves nothing of the target: its one read of a holder is the caller's own read door, projected to `id`, and its answer only decides whether the write-path reference check answers the holder. - Unchanged: which writes are refused, `400`, `VALIDATION_FAILED`, `fields[].field: user_id`, `fields[].code: reference_not_found`, the system-write stand-down, global grants, and an update that leaves the holder as stored (judged on membership, as before). No new error code. Route choice: the engine's own reference check answers the holder, rather than the plugin rebuilding the engine's sentence. `@objectstack/plugin-security` carries no runtime dependency on `@objectstack/objectql`, where the sentence builder and its message context (negotiated locale, translated field label) live, and handing the holder to the engine's own door keeps every stage after the membership hook identical for any holder the caller cannot read. No engine file is touched. ## Tests `packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts`, one new top-level `describe` in the existing harness (a real `ObjectQL` engine over a real SQL driver with the real `SecurityPlugin`), 10 cases: - CONTROL — the caller's own `sys_user` read returns its own row and never the named holder, which exists where the case needs it. - `sys_user_position` and `sys_user_permission_set`, insert and update-by-id — the whole answer an HTTP door serves (`resolveThrownHttpError`, serialized) is byte-identical across three stores of the same holder id, refused at `user_id` with `reference_not_found`; nothing is stored, the updated row is untouched (4 cases). - A holder the caller cannot read is read only through the caller's own door, projected to its `id`, and no membership is read for it (every read naming the holder is spied: non-system, the caller's `userId`, `fields: ['id']`) — the behaviour pin the census row above names. - A holder the caller can read keeps the membership refusal (`constraint.target: sys_member`), and a readable member is admitted. - The refusal set is unchanged on the path the engine's check does not judge: an update that moves only the organization is still refused for a non-member and lands once the holder is a member there. - Under the shipped organization administrator's `sys_user` read, a member is admitted, and a user outside the organization gets the same answer a missing user gets. - A composition with no `sys_user` object: membership is judged in the plugin. The pre-existing 21 cases of the file pass unchanged. `packages/core/src/security/second-object-read-exposure.pin.test.ts`: one `CALLERS` row, `kind: 'not-a-served-read'`, naming the pin above. ## Acceptance notes - Under the shipped `organization_admin` set, a user outside the caller's organization is unreadable to that caller, so a grant naming one now reads the engine's `reference_not_found` sentence instead of the membership sentence. The refusal itself and its codes are unchanged; this is the platform's answer for every user reference that caller cannot read. - The refused entry's `constraint` follows the entry: `{ target: sys_user }` from the reference check for a holder the caller cannot read, `{ target: sys_member, targetField, organization }` from the membership refusal otherwise. - `Clause-②: no` without an arm: no write accepted before is refused now, and none refused before is accepted. ## Verification Every reading below except the first three ablations was taken at head `367379988` (after the last merge of `main`, at `7c523aa10`). <details> <summary>Package: build, typecheck, full suite</summary> Under the verify lock, after rebuilding the dependency closure (`--filter '@objectstack/plugin-security^...'`): `pnpm --filter @objectstack/plugin-security build`, `pnpm --filter @objectstack/spec check:generated` (14 artifacts up to date), `pnpm --filter @objectstack/plugin-security typecheck` (incl. `check:test-typecheck`: 0 errors), the core `repo` project (`Test Files 7 passed (7)`, `Tests 96 passed (96)`), then the plugin-security suite: `Test Files 248 passed (248)`, `Tests 5834 passed | 45 skipped (5879)`, lock VERDICT command-exit 0. </details> <details> <summary>Ablations: 5 legs, each red then restored</summary> Taken at `ee912fb9` (the fix commit; the test file has since lost one type cast and nothing else). Each leg mutated through `scripts/ablation-replace.mjs`, which proves the mutation landed (anchor count, blob hash) and that the restore matched HEAD with `git diff HEAD` empty. The tests import the hook from `src/`, so no `dist/` is involved. 1. The insert-path hand-off removed: 2 of 30 red, exactly the two insert cases. 2. The update-path hand-off removed: 2 of 30 red, exactly the two update cases. 3. The `sys_user`-registered condition removed from the hand-off guard: 1 of 30 red, the no-`sys_user` composition case (the write is no longer refused). 4. At `367379988`, the census row's key renamed: 2 of 4 red in the core pin (the unclassified-caller case, which names this file, and the vacuity control). 5. At `367379988`, the insert-path hand-off removed: 3 of 31 red, the two insert cases and the read-shape pin. </details> <details> <summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands` derives 69 families at `367379988` (the core census file adds six). All 69 exit 0 at `367379988`, `check:type-check-debt` included (run under the verify lock). `check:dts-closure` and `check:dual-build-cjs-loads` first read exit 1 while this worktree's own closure build was rewriting `packages/spec/dist` beside them; re-run after it, both exit 0. `--ran` reconciliation: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN. Repo-wide scans and every other family are CI's. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_012dn2AdqWnb3ndnEacKvJvF)_ <!-- signed: security#1 · os-bob · session_012dn2AdqWnb3ndnEacKvJvF --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82cccaf commit e669735

4 files changed

Lines changed: 360 additions & 14 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a refused grant holder the caller cannot read is answered as a holder that does not exist
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A non-system write to `sys_user_position` or `sys_user_permission_set` that names a `user_id` the caller's own `sys_user` read does not return is now answered by the engine's reference check, the way every other user reference that caller names is answered: `400 VALIDATION_FAILED` with the engine's `reference_not_found` entry at `user_id`, in the caller's locale. A holder the caller can read is judged on organization membership as before and keeps the membership refusal with its remedy. Which writes are refused is unchanged, and so are the status and the codes: only the field entry a holder the caller cannot read receives is the reference check's. Under the shipped organization administrator's `sys_user` read (its own row and its organization's members), a user outside the organization now receives that entry.

‎packages/core/src/security/second-object-read-exposure.pin.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,18 @@ const CALLERS: Record<string, Classification> = {
264264
"resolves on the target (the engine's `referenceExists` probe) and answers `reference_not_found` or nothing, " +
265265
'never a field of the target',
266266
},
267+
// The grant-holder membership refusal's hand-off to that check.
268+
'packages/plugins/plugin-security/src/grant-holder-membership-refusal.ts': {
269+
kind: 'not-a-served-read',
270+
reason:
271+
"the grant-holder membership refusal (`referenceCheckAsksCaller`, `callerReadsHolder`) asks whether a grant's `user_id` is a reference " +
272+
"the write-path reference check judges. Its one read of the target is the CALLER's own read door, projected to " +
273+
'`id`, and its answer only decides whether that check (`engine-referential-integrity.ts`, its row) answers the ' +
274+
'holder; it reads no field of the target and serves nothing of it. Pinned by ' +
275+
'`packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts` (a holder the caller cannot read ' +
276+
"is read only through the caller's own door, projected to `id`, and the answer served is the same whatever the " +
277+
'target holds)',
278+
},
267279
// [#25522 cut 14] The validation-predicate reads, moved out of engine.ts.
268280
'packages/objectql/src/engine-master-detail-related.ts': {
269281
kind: 'not-a-served-read',

‎packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts‎

Lines changed: 224 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import { ObjectQL } from '@objectstack/objectql';
2727
import { SqlDriver } from '@objectstack/driver-sql';
2828
import { resolveThrownHttpError } from '@objectstack/types';
2929
import type { PermissionSet } from '@objectstack/spec/security';
30+
import { ORGANIZATION_ADMIN } from '@objectstack/spec/identity';
31+
import { defaultPermissionSets } from './objects/default-permission-sets.js';
3032

3133
import { SecurityPlugin } from './security-plugin.js';
3234
import { SysUser, SysMember } from '@objectstack/platform-objects/identity';
@@ -74,6 +76,28 @@ const QA_READ_ONLY_ON_GRANTS = {
7476
},
7577
} as unknown as PermissionSet;
7678

79+
/** The shipped organization administrator's `sys_user` read: its object entry and its row policies. */
80+
const SHIPPED_ORG_ADMIN = defaultPermissionSets.find((s) => s.name === ORGANIZATION_ADMIN)!;
81+
const SHIPPED_USER_READ_POLICIES = (SHIPPED_ORG_ADMIN.rowLevelSecurity ?? []).filter((p) => p.object === 'sys_user');
82+
83+
/** The superuser wildcard, with the shipped organization administrator's `sys_user` read (its own row and its organization's members). */
84+
const QA_ADMIN_SHIPPED_USER_READ = {
85+
name: 'qa_admin_shipped_user_read',
86+
label: 'QA Admin, shipped user read',
87+
objects: { ...(QA_ADMIN as any).objects, sys_user: (SHIPPED_ORG_ADMIN.objects as any).sys_user },
88+
rowLevelSecurity: SHIPPED_USER_READ_POLICIES,
89+
} as unknown as PermissionSet;
90+
91+
/** The same, with a `sys_user` read NARROWER than organization membership: its own row only. */
92+
const QA_ADMIN_OWN_USER_ROW = {
93+
name: 'qa_admin_own_user_row',
94+
label: 'QA Admin, own user row',
95+
objects: { ...(QA_ADMIN as any).objects, sys_user: (SHIPPED_ORG_ADMIN.objects as any).sys_user },
96+
rowLevelSecurity: SHIPPED_USER_READ_POLICIES.filter((p) => p.name === 'sys_user_self'),
97+
} as unknown as PermissionSet;
98+
99+
const PERMISSION_SETS = [MEMBER_DEFAULT, QA_ADMIN, QA_READ_ONLY_ON_GRANTS, QA_ADMIN_SHIPPED_USER_READ, QA_ADMIN_OWN_USER_ROW];
100+
77101
const SYS = { isSystem: true } as const;
78102
const ORG_A = 'org_a';
79103
const ORG_B = 'org_b';
@@ -119,6 +143,8 @@ interface BootOptions {
119143
* every membership read fails.
120144
*/
121145
members?: boolean | 'unprovisioned';
146+
/** `false` registers no `sys_user` object: the engine's reference check has no holder to read. */
147+
users?: boolean;
122148
}
123149

124150
/** The positions the fixture's assignment rows name, as their definitions. */
@@ -154,8 +180,10 @@ async function boot(opts: BootOptions = {}) {
154180
// name: the engine's own `user_id` reference check reads the holder through
155181
// the caller's read door and finds it, so what is measured here is the
156182
// membership predicate alone.
157-
if (!engine.registry.getObject(SysUser.name)) engine.registry.registerObject(SysUser as never, 'qa.authz-read-set');
158-
await provisionRigUsers(engine, RIG_USERS);
183+
if (opts.users !== false) {
184+
if (!engine.registry.getObject(SysUser.name)) engine.registry.registerObject(SysUser as never, 'qa.authz-read-set');
185+
await provisionRigUsers(engine, RIG_USERS);
186+
}
159187
if (opts.members === 'unprovisioned') engine.registry.registerObject(SysMember as never, 'qa.authz-read-set');
160188
engines.push(engine);
161189

@@ -167,7 +195,7 @@ async function boot(opts: BootOptions = {}) {
167195
metadata: {
168196
get: async (type: string, name: string) =>
169197
(type === 'position' ? QA_POSITIONS.find((p) => p.name === name) : engine.getSchema(name)) ?? null,
170-
list: async (type: string) => (type === 'position' ? QA_POSITIONS : [MEMBER_DEFAULT, QA_ADMIN, QA_READ_ONLY_ON_GRANTS]),
198+
list: async (type: string) => (type === 'position' ? QA_POSITIONS : PERMISSION_SETS),
171199
},
172200
...(opts.walled
173201
? { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture: 'isolated' } }
@@ -446,6 +474,199 @@ describe('single posture', () => {
446474
});
447475
});
448476

477+
// ---------------------------------------------------------------------------
478+
// A holder the caller cannot read
479+
// ---------------------------------------------------------------------------
480+
481+
/**
482+
* A refused grant holder the caller cannot read is answered as a holder that
483+
* does not exist — one answer, the engine's own reference refusal, whatever
484+
* that holder is. One id is named in three stores (a member of the row's
485+
* organization, a member of another organization only, no user at all) by a
486+
* caller whose `sys_user` read is narrower than organization membership, and
487+
* the whole answer an HTTP door serves is compared byte for byte. A holder the
488+
* caller CAN read keeps the membership refusal, and the refusal SET is the
489+
* ruled one on every path.
490+
*/
491+
describe('a refused grant holder the caller cannot read is answered as a holder that does not exist', () => {
492+
const HOLDER = 'u_x';
493+
type Store = 'member here' | 'member elsewhere' | 'no such user';
494+
const STORES: readonly Store[] = ['member here', 'member elsewhere', 'no such user'];
495+
496+
/** May write both grant tables; reads its own `sys_user` row only. Not a member of org_a itself. */
497+
const OWN_ROW_ADMIN_A = { userId: 'u_admin_a', positions: [], permissions: ['qa_admin_own_user_row'], tenantId: ORG_A };
498+
/** The same grants, held by a member of org_a. */
499+
const OWN_ROW_MEMBER_A = { userId: 'u_member_a', positions: [], permissions: ['qa_admin_own_user_row'], tenantId: ORG_A };
500+
501+
/** `boot`, then the holder as `store` says: a `sys_user` row and one membership, or nothing at all. */
502+
async function bootWith(store: Store, opts: BootOptions = { walled: true }) {
503+
const h = await boot(opts);
504+
if (store !== 'no such user') {
505+
await provisionRigUsers(h.engine, [HOLDER]);
506+
const org = store === 'member here' ? ORG_A : ORG_B;
507+
await h.engine.insert(
508+
'sys_member', { id: 'm_x', user_id: HOLDER, organization_id: org, role: 'member' },
509+
{ context: { isSystem: true, tenantId: org } } as any,
510+
);
511+
}
512+
return h;
513+
}
514+
515+
/** Everything an HTTP door serves for this throw: status, codes, message and every field entry. */
516+
const wireOf = (e: unknown): string => JSON.stringify(resolveThrownHttpError(e));
517+
518+
/** One answer across the stores, and that answer the reference refusal at `user_id`. */
519+
function expectOneHolderAnswer(answers: ReadonlyMap<Store, unknown>) {
520+
const wires = STORES.map((s) => wireOf(answers.get(s)));
521+
for (const [i, s] of STORES.entries()) expect(wires[i], s).toBe(wires[0]);
522+
const env = envelopeOf(answers.get(STORES[0]));
523+
expectMembershipRefusal(answers.get(STORES[0]));
524+
expect(env.fields[0]?.constraint?.target).toBe('sys_user');
525+
}
526+
527+
it('CONTROL: the caller reads its own sys_user row and never the holder, which exists in two of the stores', async () => {
528+
for (const store of STORES) {
529+
const h = await bootWith(store);
530+
const seen = await h.engine.find('sys_user', { where: { id: { $in: ['u_admin_a', HOLDER] } }, context: OWN_ROW_ADMIN_A });
531+
expect(seen.map((r) => r.id), store).toEqual(['u_admin_a']);
532+
expect(await rowsOf(h, 'sys_user', { id: HOLDER }), store).toHaveLength(store === 'no such user' ? 0 : 1);
533+
expect(await rowsOf(h, 'sys_member', { user_id: HOLDER }), store).toHaveLength(store === 'no such user' ? 0 : 1);
534+
}
535+
});
536+
537+
for (const [object, row] of [
538+
['sys_user_position', { position: 'qa_rep' }],
539+
['sys_user_permission_set', { permission_set: 'qa_rep_set' }],
540+
] as const) {
541+
it(`${object}: an insert naming it gets one answer in every store, and stores nothing`, async () => {
542+
const answers = new Map<Store, unknown>();
543+
for (const store of STORES) {
544+
const h = await bootWith(store);
545+
answers.set(store, await refusalOf(() => h.engine.insert(
546+
object, { user_id: HOLDER, ...row }, { context: OWN_ROW_ADMIN_A } as any,
547+
)));
548+
expect(await rowsOf(h, object, { user_id: HOLDER }), store).toHaveLength(0);
549+
}
550+
expectOneHolderAnswer(answers);
551+
});
552+
553+
it(`${object}: an update by id that moves the holder to it gets one answer in every store; the stored row is untouched`, async () => {
554+
const answers = new Map<Store, unknown>();
555+
for (const store of STORES) {
556+
const h = await bootWith(store);
557+
await h.engine.insert(
558+
object, { id: 'g1', user_id: 'u_member_a', organization_id: ORG_A, ...row },
559+
{ context: { isSystem: true, tenantId: ORG_A } } as any,
560+
);
561+
answers.set(store, await refusalOf(() => h.engine.update(
562+
object, { id: 'g1', user_id: HOLDER }, { context: OWN_ROW_ADMIN_A } as any,
563+
)));
564+
const [stored] = await rowsOf(h, object, { id: 'g1' });
565+
expect(stored?.user_id, store).toBe('u_member_a');
566+
}
567+
expectOneHolderAnswer(answers);
568+
});
569+
}
570+
571+
it("a holder the caller cannot read is read only through the caller's own door, projected to its id, and no membership is read for it", async () => {
572+
for (const store of STORES) {
573+
const h = await bootWith(store);
574+
const reads: Array<{ object: string; query: any }> = [];
575+
const find = h.engine.find.bind(h.engine);
576+
vi.spyOn(h.engine, 'find').mockImplementation(async (object: string, query?: any) => {
577+
reads.push({ object, query });
578+
return find(object, query);
579+
});
580+
expectMembershipRefusal(await refusalOf(() => h.engine.insert(
581+
'sys_user_position', { user_id: HOLDER, position: 'qa_rep' }, { context: OWN_ROW_ADMIN_A } as any,
582+
)), store);
583+
const namesHolder = (q: any) => JSON.stringify(q?.where ?? null).includes(`"${HOLDER}"`);
584+
const holderReads = reads.filter((r) => r.object === 'sys_user' && namesHolder(r.query));
585+
expect(holderReads.length, store).toBeGreaterThan(0);
586+
for (const r of holderReads) {
587+
expect(r.query?.context?.isSystem, store).toBeFalsy();
588+
expect(r.query?.context?.userId, store).toBe(OWN_ROW_ADMIN_A.userId);
589+
expect(r.query?.fields, store).toEqual(['id']);
590+
}
591+
expect(reads.filter((r) => r.object === 'sys_member' && namesHolder(r.query)), store).toEqual([]);
592+
}
593+
});
594+
595+
it('a holder the caller CAN read keeps the membership refusal; a readable member is admitted', async () => {
596+
const h = await bootWith('member elsewhere');
597+
// The caller's own row is readable, and the caller is no member of org_a.
598+
const err = await refusalOf(() => h.engine.insert(
599+
'sys_user_position', { user_id: 'u_admin_a', position: 'qa_rep' }, { context: OWN_ROW_ADMIN_A } as any,
600+
));
601+
expectMembershipRefusal(err);
602+
expect(envelopeOf(err).fields[0]?.constraint?.target).toBe('sys_member');
603+
expect(await rowsOf(h, 'sys_user_position', { user_id: 'u_admin_a' })).toHaveLength(0);
604+
605+
await h.engine.insert(
606+
'sys_user_position', { user_id: 'u_member_a', position: 'qa_rep' }, { context: OWN_ROW_MEMBER_A } as any,
607+
);
608+
const [row] = await rowsOf(h, 'sys_user_position', { user_id: 'u_member_a' });
609+
expect(row?.organization_id).toBe(ORG_A);
610+
});
611+
612+
it('the refusal set is unchanged: an update that moves only the organization still judges a holder the caller cannot read on membership', async () => {
613+
const h = await bootWith('member here', {});
614+
await h.engine.insert(
615+
'sys_user_position', { id: 'mv', user_id: HOLDER, position: 'qa_rep', organization_id: ORG_A },
616+
{ context: { isSystem: true, tenantId: ORG_A } } as any,
617+
);
618+
const err = await refusalOf(() => h.engine.update(
619+
'sys_user_position', { id: 'mv', organization_id: ORG_B }, { context: OWN_ROW_ADMIN_A } as any,
620+
));
621+
expectMembershipRefusal(err);
622+
expect((await rowsOf(h, 'sys_user_position', { id: 'mv' }))[0]?.organization_id).toBe(ORG_A);
623+
624+
// Positive control: once the holder is a member there, the same move lands.
625+
await h.engine.insert(
626+
'sys_member', { id: 'm_x_b', user_id: HOLDER, organization_id: ORG_B, role: 'member' },
627+
{ context: { isSystem: true, tenantId: ORG_B } } as any,
628+
);
629+
await h.engine.update('sys_user_position', { id: 'mv', organization_id: ORG_B }, { context: OWN_ROW_ADMIN_A } as any);
630+
expect((await rowsOf(h, 'sys_user_position', { id: 'mv' }))[0]?.organization_id).toBe(ORG_B);
631+
});
632+
633+
it('under the shipped user read, a member is admitted, and a user outside the organization gets the answer a missing user gets', async () => {
634+
const orgAPeers = (store: Store) => [
635+
'u_admin_a', ...MEMBERSHIPS.filter((m) => m.organization_id === ORG_A).map((m) => m.user_id),
636+
...(store === 'member here' ? [HOLDER] : []),
637+
];
638+
const caller = (store: Store) => ({
639+
userId: 'u_admin_a', positions: [], permissions: ['qa_admin_shipped_user_read'], tenantId: ORG_A,
640+
org_user_ids: orgAPeers(store),
641+
});
642+
const answers = new Map<Store, unknown>();
643+
for (const store of STORES) {
644+
const h = await bootWith(store);
645+
const write = () => h.engine.insert(
646+
'sys_user_position', { user_id: HOLDER, position: 'qa_rep' }, { context: caller(store) } as any,
647+
);
648+
if (store === 'member here') {
649+
await write();
650+
expect(await rowsOf(h, 'sys_user_position', { user_id: HOLDER })).toHaveLength(1);
651+
} else {
652+
answers.set(store, await refusalOf(write));
653+
}
654+
}
655+
expect(wireOf(answers.get('member elsewhere'))).toBe(wireOf(answers.get('no such user')));
656+
expectMembershipRefusal(answers.get('no such user'));
657+
});
658+
659+
it('a composition with no sys_user object leaves the reference check nothing to read: membership is judged here', async () => {
660+
const h = await boot({ walled: true, users: false });
661+
expectMembershipRefusal(await refusalOf(() => h.engine.insert(
662+
'sys_user_position', { user_id: 'u_member_b', position: 'qa_rep' }, { context: ADMIN_A } as any,
663+
)));
664+
expect(await rowsOf(h, 'sys_user_position', { user_id: 'u_member_b' })).toHaveLength(0);
665+
await h.engine.insert('sys_user_position', { user_id: 'u_member_a', position: 'qa_rep' }, { context: ADMIN_A } as any);
666+
expect(await rowsOf(h, 'sys_user_position', { user_id: 'u_member_a' })).toHaveLength(1);
667+
});
668+
});
669+
449670
// ---------------------------------------------------------------------------
450671
// Registration
451672
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)