Skip to content

Commit a1260e5

Browse files
committed
test(dogfood): an owner shares one public_read_write invoice with an external
On a showcase boot the record's owner shares one invoice with an external member over the REST share door (201) and the external then reads that invoice and no other; the same share to an ordinary member answers 422 SHARING_NOT_ENABLED. Adds the changeset. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjwMndyviznGagEUcX7QVx
1 parent 9824f02 commit a1260e5

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/plugin-sharing': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat: a share or sharing rule on a public object reaches an external member of the record's organization
7+
8+
Clause-②: yes (widening: the grant door and the materialiser admit one recipient population on effective-public objects)
9+
Metadata-protocol: semantic
10+
11+
An external member (`sys_member.role` exactly `external`) reads only the rows it owns and the rows shared to it, on every object. On a `public_read_write` object, or a system object with no `sharingModel`, a share to it used to be refused as one that would never take effect. For that member it is the only thing that does.
12+
13+
- **Manual shares.** `SharingService.grant` now admits a `user` recipient on such an object when the recipient is an `external` member of the record's organization. Every other recipient keeps the 422 `SHARING_NOT_ENABLED`, with a message that says the share is consulted for external members only. A user who is external in one organization and a member of another is judged in the record's organization. `controlled_by_parent`, owner-less, federated phantom-anchor and bypass objects are refused for every recipient, as before.
14+
- **Sharing rules.** A rule's pass writes rows for the external members it expands to and skips the internal ones without failing the pass. When it skips any, the evaluate result carries a one-line `note`, and the pass logs the count once.
15+
- **Lint.** `sharing-rule-object-not-shareable` on a public object is now a warning: the rule is effective for external recipients only. `os explain` says when to keep it and when to change the object's `sharingModel`.

‎packages/qa/dogfood/test/external-member-directory.dogfood.test.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@
3333
// - list-teams and get-full-organization's teams answer an external the
3434
// teams it sits on; list-invitations answers it its own invitations.
3535
//
36+
// [#25549] A share to an external on a `public_read_write` object:
37+
//
38+
// - the record's owner shares one invoice with an external over the REST
39+
// share door and gets 201; the external then reads that invoice, and no
40+
// other, where before it read none;
41+
// - the same share to an ordinary member is refused 422
42+
// `SHARING_NOT_ENABLED`: on a public object no gate consults it for staff.
43+
//
3644
// Fixtures are synthetic. ⚠️ No test title states a value.
3745

3846
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
@@ -179,12 +187,12 @@ describe('[#25233] an external organization member reads its own identity rows a
179187
// same read on both objects, and neither owns any of the rows.
180188
const portalLines = await catalogPermissionSet(ql, portalLinesSet.name);
181189
expect(portalLines, 'fixture invoice permission set served by the security catalog').toBeTruthy();
182-
for (const who of ['supplierD', 'member'] as const) {
190+
for (const who of ['supplierD', 'member', 'supplierB'] as const) {
183191
await ql.insert('sys_user_permission_set', { user_id: uid[who], permission_set: portalLines.name, organization_id: orgId }, { context: SYS });
184192
}
185193
const account = await ql.insert('showcase_account', { name: 'Account 25233', status: 'active', organization_id: orgId }, { context: SYS });
186194
const product = await ql.insert('showcase_product', { name: 'Product 25233', organization_id: orgId }, { context: SYS });
187-
for (const key of ['shared', 'other'] as const) {
195+
for (const key of ['shared', 'other', 'grant'] as const) {
188196
const inv = await ql.insert(MASTER, {
189197
name: `INV-25233-${key}`, account: String(account.id), status: 'draft', owner_id: uid.owner, organization_id: orgId,
190198
}, { context: SYS });
@@ -362,6 +370,35 @@ describe('[#25233] an external organization member reads its own identity rows a
362370
expect((await auth('supplierB', `list-invitations?organizationId=${orgId}`)).body).toEqual([]);
363371
});
364372

373+
it('[#25549] the owner shares one public_read_write invoice with an external, who then reads it and no other', async () => {
374+
// Before: the floored external owns no invoice and holds no share, so it reads none.
375+
const before = await ids('supplierB', `/data/${MASTER}?limit=200`);
376+
expect(before.status).toBe(200);
377+
expect(before.ids).toEqual([]);
378+
379+
const res = await stack.apiAs(token.owner, 'POST', `/data/${MASTER}/${row.inv_grant}/shares`, {
380+
recipientId: uid.supplierB, accessLevel: 'read',
381+
});
382+
expect(res.status).toBe(201);
383+
const share = await res.json() as Row;
384+
expect(share).toMatchObject({ object_name: MASTER, record_id: row.inv_grant, recipient_id: uid.supplierB, organization_id: orgId });
385+
386+
const after = await ids('supplierB', `/data/${MASTER}?limit=200`);
387+
expect(after.status).toBe(200);
388+
expect(after.ids).toEqual([row.inv_grant]);
389+
});
390+
391+
it('[#25549] the same share to an ordinary member is refused 422 SHARING_NOT_ENABLED', async () => {
392+
const res = await stack.apiAs(token.owner, 'POST', `/data/${MASTER}/${row.inv_grant}/shares`, {
393+
recipientId: uid.member, accessLevel: 'read',
394+
});
395+
expect(res.status).toBe(422);
396+
const body = await res.json() as { error?: { code?: string; message?: string } };
397+
expect(body.error?.code).toBe('SHARING_NOT_ENABLED');
398+
const rows = await findRows(ql, 'sys_record_share', { object_name: MASTER, record_id: row.inv_grant, recipient_id: uid.member });
399+
expect(rows).toHaveLength(0);
400+
});
401+
365402
it('E5: an owner cannot give a member external beside another role', async () => {
366403
const [row] = await findRows(ql, 'sys_member', { user_id: uid.member, organization_id: orgId }, 1);
367404
const res = await stack.apiAs(token.owner, 'POST', '/auth/organization/update-member-role', {

0 commit comments

Comments
 (0)