From 95d33edf91644656327e1a435f8bec3109f63108 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:13:25 +0000 Subject: [PATCH 1/5] test(dogfood): pin GET /share-links self-scoped list for a plain member Route-level pin through the real runtime composition: a plain member lists exactly their own links, foreign creators stay absent, the admin path and the anonymous 401 are unchanged. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../share-links-self-list.dogfood.test.ts | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts diff --git a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts new file mode 100644 index 00000000000..388d0ac8f1f --- /dev/null +++ b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts @@ -0,0 +1,216 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #21328 — `GET /share-links` is SELF-SCOPED for every signed-in caller, as + * ADR-0111's surface table rules it ("list is self-scoped"), not only for a + * caller whose permission sets happen to grant `sys_share_link`. + * + * ## The defect + * + * Both share-link doors force `createdBy` to the caller and hand the list to + * `ShareLinkService.listLinks`, which read `sys_share_link` under the CALLER's + * context. The platform `member_default` set grants nothing on that object, so + * every plain member's list was refused at the object-CRUD gate — with no + * filter and with any object — and the console's Share dialog, which loads + * this list on open, showed an error for every plain member on every record. + * An admin's list answered 200, so an admin-driven test was green throughout: + * the persona IS the gate, which is why every case here runs as a member. + * + * ## The fix under test, and what it must NOT do + * + * The service reads under the system context ONLY when the caller has a + * non-empty user identity AND the creator filter is that identity, and returns + * only rows the caller created — by the one creator rule `revokeLink` also + * adjudicates with. So the refusals below are as load-bearing as the success: + * + * - `[foreign]` member B's links and the admin's, minted on the SAME record, + * never appear in member A's list: not through the record filter, not + * without it, and not with a `createdBy` query parameter naming B (the door + * ignores it and forces the caller's own id). + * - `[no-grant]` the member still holds no `sys_share_link` grant: the fix is + * a scoped system read, never a permission-set widening, so the generic + * data door keeps refusing them the table. + * - `[admin]` the admin's list is unchanged: their own links only. + * - `[anonymous]` a caller with no identity never reaches the service. + * - `[secret]` the member's row carries its token (the console builds the URL + * from it) and never the password hash. + * + * ⚠️ Read `[foreign]`, `[admin]`, `[anonymous]` and `[no-grant]` honestly: on + * the unfixed build they pass too — the member's list was refused outright, + * so it could not show anyone's rows. They become load-bearing once the + * member's list answers, which is exactly when they prove it answers narrowly. + * A reverse verification of this file therefore expects the `[own]` / `[empty]` + * / `[secret]` cases to move and the others to sit still. + * + * ## Why the members' links are minted through the service, not the route + * + * A plain showcase member can SEE no `publicSharing` object, and minting + * through the route re-reads the record under the minter's context — so a + * route mint would need a permission set that makes the member not plain. + * Minting is not this file's subject (who may mint is a separate, open + * question); listing is. So each member's links are minted through the very + * service instance the door calls, under a system context that carries the + * member's identity — `createLink` stamps `created_by` from it. The admin's + * link goes through the real mint route. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; +import { showcaseAppDefaultSecurity } from './showcase-security.js'; + +const SYS = { isSystem: true } as const; +const OBJECT = 'showcase_client_brief'; +/** The seeded brief that satisfies the object's `eligibility` predicate. */ +const PUBLISHED_BRIEF = 'Northwind — Website Relaunch brief'; + +const A_EMAIL = 'share-links.member-a.21328@verify.test'; +const B_EMAIL = 'share-links.member-b.21328@verify.test'; +const C_EMAIL = 'share-links.member-c.21328@verify.test'; + +/** The listed link ids, sorted — compared as a set, never as a count. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const idsOf = (rows: any[]): string[] => rows.map((r) => String(r?.id)).sort(); + +describe('#21328: GET /share-links is self-scoped for a plain member', () => { + let stack: VerifyStack; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let ql: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let shareLinks: any; + let adminTok = ''; + let aTok = ''; + let bTok = ''; + let cTok = ''; + let bId = ''; + let briefId = ''; + /** Member A's links: one password-protected, one plain. */ + let aLinks: string[] = []; + let aProtected: { id: string; token: string } = { id: '', token: '' }; + let bLinks: string[] = []; + let adminLinks: string[] = []; + + const userIdOf = async (email: string): Promise => + String((await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id ?? ''); + + /** Mint through the service the door calls, attributed to `userId`. */ + const mintFor = async (userId: string, extra: Record = {}) => + shareLinks.createLink({ object: OBJECT, recordId: briefId, ...extra }, { isSystem: true, userId }); + + /** `GET /share-links` as whoever holds `token`. */ + const list = async (token: string | null, query = '') => { + const path = `/share-links${query}`; + const res = token === null ? await stack.api(path) : await stack.apiAs(token, 'GET', path); + const text = await res.text(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let body: any = null; + try { body = JSON.parse(text); } catch { /* the status still reports */ } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows: any[] = Array.isArray(body?.data) ? body.data : []; + return { status: res.status, text, body, rows }; + }; + + beforeAll(async () => { + stack = await bootStack(showcaseStack, { security: showcaseAppDefaultSecurity() }); + adminTok = await stack.signIn(); // the seeded admin first, so the sign-ups below are plain members + aTok = await stack.signUp(A_EMAIL); + bTok = await stack.signUp(B_EMAIL); + cTok = await stack.signUp(C_EMAIL); + ql = await stack.kernel.getServiceAsync('objectql'); + shareLinks = await stack.kernel.getServiceAsync(SHARE_LINK_SERVICE); + expect(shareLinks, 'the sharing plugin registers the share-link service').toBeTruthy(); + + const aId = await userIdOf(A_EMAIL); + bId = await userIdOf(B_EMAIL); + expect(aId && bId, 'both members exist').toBeTruthy(); + + briefId = String((await ql.findOne(OBJECT, { where: { title: PUBLISHED_BRIEF }, context: SYS }))?.id ?? ''); + expect(briefId, 'the seeded published brief').toBeTruthy(); + + const protectedLink = await mintFor(aId, { password: 'pw-21328', label: 'a-protected' }); + const plainLink = await mintFor(aId, { label: 'a-plain' }); + aProtected = { id: String(protectedLink.id), token: String(protectedLink.token) }; + aLinks = [String(protectedLink.id), String(plainLink.id)].sort(); + bLinks = [String((await mintFor(bId, { label: 'b-plain' })).id)]; + + // The admin's link goes through the real mint door, on the SAME record. + const minted = await stack.apiAs(adminTok, 'POST', '/share-links', { object: OBJECT, recordId: briefId }); + const mintedText = await minted.text(); + expect(minted.status, `admin mint: ${mintedText}`).toBe(201); + adminLinks = [String(JSON.parse(mintedText)?.data?.id)]; + }, 300_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + // ── the fixture's own preconditions ─────────────────────────────────────── + + it('[no-grant] the member holds no sys_share_link grant — the generic data door still refuses the table', async () => { + const res = await stack.apiAs(aTok, 'GET', '/data/sys_share_link'); + const text = await res.text(); + expect(res.status, `the member must stay ungranted on sys_share_link: ${text}`).toBe(403); + expect(JSON.parse(text)).toMatchObject({ error: { code: 'PERMISSION_DENIED' } }); + }); + + it('[fixture] every link sits on the same record, so a record filter cannot separate them', async () => { + const rows = await ql.find('sys_share_link', { where: { record_id: briefId }, context: SYS }); + expect(idsOf(rows)).toEqual([...aLinks, ...bLinks, ...adminLinks].sort()); + }); + + // ── the defect ──────────────────────────────────────────────────────────── + + it('[own] a plain member lists exactly their own links, with no filter', async () => { + const res = await list(aTok); + expect(res.status, res.text).toBe(200); + expect(idsOf(res.rows)).toEqual(aLinks); + }); + + it('[own] the Share dialog\'s request — object + record — answers exactly the member\'s links on that record', async () => { + const res = await list(aTok, `?object=${OBJECT}&recordId=${encodeURIComponent(briefId)}`); + expect(res.status, res.text).toBe(200); + expect(idsOf(res.rows), 'B\'s and the admin\'s links on the same record are absent').toEqual(aLinks); + }); + + it('[empty] a plain member with no links gets an empty list, not a refusal', async () => { + const res = await list(cTok, `?object=${OBJECT}&recordId=${encodeURIComponent(briefId)}`); + expect(res.status, res.text).toBe(200); + expect(res.rows).toEqual([]); + }); + + it('[secret] the member\'s row carries its token and never the password hash', async () => { + const res = await list(aTok); + expect(res.status, res.text).toBe(200); + const row = res.rows.find((r) => String(r?.id) === aProtected.id); + expect(row, 'the password-protected link is listed').toBeTruthy(); + expect(row.token, 'the console builds the URL from the token').toBe(aProtected.token); + expect(Object.keys(row), 'the hash is an internal column and never leaves').not.toContain('password_hash'); + }); + + // ── what it must NOT widen ──────────────────────────────────────────────── + + it('[foreign] a createdBy query naming another member is ignored — still only the caller\'s own links', async () => { + const res = await list(aTok, `?createdBy=${encodeURIComponent(bId)}`); + expect(res.status, res.text).toBe(200); + expect(idsOf(res.rows)).toEqual(aLinks); + }); + + it('[foreign] member B sees only B\'s link, never A\'s', async () => { + const res = await list(bTok); + expect(res.status, res.text).toBe(200); + expect(idsOf(res.rows)).toEqual(bLinks); + }); + + it('[admin] the admin\'s list is unchanged — their own link only', async () => { + const res = await list(adminTok); + expect(res.status, res.text).toBe(200); + expect(idsOf(res.rows)).toEqual(adminLinks); + }); + + it('[anonymous] no identity never reaches the service', async () => { + const res = await list(null); + expect(res.status, res.text).toBe(401); + expect(res.body).toMatchObject({ error: { code: 'UNAUTHENTICATED' } }); + }); +}); From 4db5599d465b025201d51629ec8f216067cf773f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:19:17 +0000 Subject: [PATCH 2/5] fix(plugin-sharing): a caller's own share-link list is self-scoped listLinks read sys_share_link under the caller's context, so the self-scoped list ADR-0111 rules (both doors force createdBy to the caller) demanded an object-level grant the member baseline does not carry, and every plain member's list was refused. The caller's own list (a non-empty user identity, and the creator filter equal to it) is now read under the system context, constrained server-side to that identity, and every row must pass the creator rule before it leaves. One helper, isLinkCreator, is that rule for both listLinks and revokeLink. Every other shape keeps the caller's context. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/share-link-service.test.ts | 146 ++++++++++++++++++ .../plugin-sharing/src/share-link-service.ts | 63 +++++++- .../share-links-self-list.dogfood.test.ts | 3 +- 3 files changed, 207 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index f88aae1d786..4cc4df8bcab 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -451,6 +451,152 @@ describe('ShareLinkService', () => { ), ).rejects.toMatchObject({ status: 404 }); }); + + // [#21328] The creator half of revoke authority is the one creator rule + // `listLinks` also reads, and that rule names no creator for a context + // without a user identity. Compared bare, `undefined === undefined` let an + // identity-less (non-system) caller revoke a link whose `created_by` the + // driver omitted. Neither HTTP door reaches this — both answer 401 first — + // so it is the internal-caller edge of the same rule, pinned fail-closed. + it('a caller with no user identity is the creator of nothing', async () => { + engine._tables.sys_share_link = [ + { id: 'shl_omitted', token: 'tok_omitted_0001', object_name: 'ai_conversations', record_id: 'c1', revoked_at: null }, + { id: 'shl_empty', token: 'tok_empty_000001', object_name: 'ai_conversations', record_id: 'c1', revoked_at: null, created_by: '' }, + ]; + await expect(service.revokeLink('shl_omitted', {})).rejects.toMatchObject({ status: 403, code: 'FORBIDDEN' }); + await expect(service.revokeLink('shl_empty', { userId: '' })).rejects.toMatchObject({ status: 403, code: 'FORBIDDEN' }); + expect(engine._tables.sys_share_link.map((r) => r.revoked_at)).toEqual([null, null]); + }); + }); + + // ── [#21328] the caller's OWN list is self-scoped (ADR-0111 surface table) ── + // + // ADR-0111 rules the list "self-scoped", and both doors force `createdBy` to + // the caller — but the read ran under the caller's context, so it demanded an + // object-level grant on `sys_share_link` the member baseline does not carry, + // and every plain member's list was refused. The fix reads the caller's OWN + // list under the system context; every other shape keeps the caller's + // context. The double below overrides only `find`, to model the one gate + // that matters: a non-system context whose sets grant nothing on + // `sys_share_link` is refused the way the security middleware refuses it + // (`PERMISSION_DENIED`, 403). + describe('[#21328] listLinks: the caller\'s own list is self-scoped', () => { + const CONVO = { object: 'ai_conversations', recordId: 'c1', audience: 'link_only', permission: 'view' } as const; + const ON_C1 = { object: 'ai_conversations', recordId: 'c1' } as const; + + /** Every `find` the service issues, with the context it ran under. */ + let seen: Array<{ object: string; where: any; context: any }>; + let svc: ShareLinkService; + let mine: string[]; + let theirs: string[]; + let admins: string[]; + + /** `who` hold an object-level read grant on `sys_share_link`; nobody else does. */ + const grantedTo = (who: string[]) => ({ + ...engine, + async find(object: string, options?: any) { + const ctx = options?.context ?? {}; + seen.push({ object, where: options?.where, context: ctx }); + if (object === 'sys_share_link' && ctx.isSystem !== true && !who.includes(ctx.userId)) { + throw Object.assign( + new Error('You do not have permission to perform this action.'), + { code: 'PERMISSION_DENIED', statusCode: 403 }, + ); + } + return engine.find(object, options); + }, + }); + + const listed = (links: Array<{ id: string }>) => links.map((l) => l.id).sort(); + const shareLinkReads = () => seen.filter((s) => s.object === 'sys_share_link'); + + beforeEach(async () => { + // Three creators' links on the SAME record, so a record filter cannot + // separate them — only the creator rule can. + mine = [ + (await service.createLink(CONVO, { userId: 'alice' })).id, + (await service.createLink({ ...CONVO, label: 'second' }, { userId: 'alice' })).id, + ].sort(); + theirs = [(await service.createLink(CONVO, { userId: 'bob' })).id]; + admins = [(await service.createLink(CONVO, { userId: 'admin' })).id]; + // Rows no caller's identity can match: one whose `created_by` the driver + // omitted, one minted under an empty identity. + engine._tables.sys_share_link.push( + { id: 'shl_omitted', token: 'tok_omitted_0001', object_name: 'ai_conversations', record_id: 'c1', revoked_at: null }, + { id: 'shl_empty', token: 'tok_empty_000001', object_name: 'ai_conversations', record_id: 'c1', revoked_at: null, created_by: '' }, + ); + seen = []; + svc = new ShareLinkService({ engine: grantedTo(['admin']) as any }); + }); + + it('a member with no sys_share_link grant lists exactly their own links', async () => { + const links = await svc.listLinks({ createdBy: 'alice' }, { userId: 'alice' }); + expect(listed(links)).toEqual(mine); + + const [read] = shareLinkReads(); + expect(read.context.isSystem, 'the own list is the one read that runs under the system context').toBe(true); + expect(read.where.created_by, 'constrained server-side to the caller').toBe('alice'); + }); + + it('through the record filter, other creators\' links on the same record are absent', async () => { + const links = await svc.listLinks({ ...ON_C1, createdBy: 'alice' }, { userId: 'alice' }); + expect(listed(links)).toEqual(mine); + // Each listed row still carries its token — the console builds the URL from it. + expect(links.every((l) => typeof l.token === 'string' && l.token.length > 0)).toBe(true); + }); + + it('a foreign creator filter keeps the caller\'s context, and is refused', async () => { + await expect(svc.listLinks({ ...ON_C1, createdBy: 'bob' }, { userId: 'alice' })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(shareLinkReads()).toHaveLength(1); + expect(shareLinkReads()[0].context).toMatchObject({ userId: 'alice' }); + expect(shareLinkReads()[0].context.isSystem).toBeUndefined(); + }); + + it('an unfiltered list keeps the caller\'s context, and is refused', async () => { + await expect(svc.listLinks({}, { userId: 'alice' })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(shareLinkReads()).toHaveLength(1); + expect(shareLinkReads()[0].context.isSystem).toBeUndefined(); + }); + + it('a caller with no user identity never takes the elevated path', async () => { + // Absent identity and absent creator filter: `undefined === undefined`. + await expect(svc.listLinks({}, {})) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + await expect(svc.listLinks({ ...ON_C1 }, {})) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + // Empty identity and empty creator filter: `'' === ''`. + await expect(svc.listLinks({ createdBy: '' }, { userId: '' })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(shareLinkReads()).toHaveLength(3); + expect(shareLinkReads().every((r) => r.context.isSystem !== true)).toBe(true); + }); + + it('the admin path is unchanged: a granted caller lists anyone\'s links under their own context', async () => { + const all = await svc.listLinks({}, { userId: 'admin' }); + expect(listed(all)).toEqual([...mine, ...theirs, ...admins, 'shl_empty', 'shl_omitted'].sort()); + const bobs = await svc.listLinks({ createdBy: 'bob' }, { userId: 'admin' }); + expect(listed(bobs)).toEqual(theirs); + expect(shareLinkReads().map((r) => r.context.isSystem)).toEqual([undefined, undefined]); + // …and the admin's OWN list answers exactly their own links, as it did. + expect(listed(await svc.listLinks({ ...ON_C1, createdBy: 'admin' }, { userId: 'admin' }))).toEqual(admins); + }); + + it('a system caller keeps its bypass', async () => { + const links = await svc.listLinks({ object: 'ai_conversations' }, { isSystem: true }); + expect(listed(links)).toEqual([...mine, ...theirs, ...admins, 'shl_empty', 'shl_omitted'].sort()); + expect(shareLinkReads()[0].context.isSystem).toBe(true); + }); + + it('revoke and list read one creator rule: what you may revoke as creator is what your list shows', async () => { + const links = await svc.listLinks({ createdBy: 'alice' }, { userId: 'alice' }); + for (const link of links) await expect(svc.revokeLink(link.id, { userId: 'alice' })).resolves.toBeUndefined(); + await expect(svc.revokeLink(theirs[0], { userId: 'alice' })).rejects.toMatchObject({ status: 403, code: 'FORBIDDEN' }); + // Revoked links leave the default list and come back under includeRevoked. + expect(await svc.listLinks({ createdBy: 'alice' }, { userId: 'alice' })).toEqual([]); + expect(listed(await svc.listLinks({ createdBy: 'alice', includeRevoked: true }, { userId: 'alice' }))).toEqual(mine); + }); }); }); diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index ccfcba1196b..51782f579dc 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -369,6 +369,29 @@ function makeError(status: number, code: string, message: string): Error { return err; } +/** + * [#21328] THE creator rule — "the caller created this link" — and the only + * place it is written. Two methods ask it and both read this one predicate: + * `revokeLink` (the creator may revoke their own link) and `listLinks` (is + * this the caller's own list, and which rows are theirs). One predicate, so + * the authority to revoke a link and the right to see it in your own list + * cannot drift apart. + * + * Keyed on the caller's OWN user identity, and only on a non-empty one. A + * context with no user identity is the creator of nothing: compared bare, + * `undefined === undefined` would make every row whose `created_by` a driver + * omitted "theirs", and `'' === ''` every row minted under an empty identity. + * The system bypass is deliberately not part of the rule — it is a different + * authority, and each caller states it beside this check. + */ +function isLinkCreator( + row: { created_by?: unknown } | null | undefined, + context: ExecutionContext, +): boolean { + const caller = context.userId; + return typeof caller === 'string' && caller.length > 0 && row?.created_by === caller; +} + export interface ShareLinkServiceOptions { engine: SharingEngine; /** Override the default SHA-256 hasher with argon2 / bcrypt for production. */ @@ -612,7 +635,10 @@ export class ShareLinkService implements IShareLinkService { // Modify-All admin): a link someone else minted on your record is your // record's exposure to kill, not only its creator's. Probed via the // late-bound sharing service; absent → creator-only (pre-D8 behaviour). - let permitted = context.isSystem === true || row.created_by === context.userId; + // + // [#21328] The creator half is `isLinkCreator` — the same rule + // `listLinks` reads to decide which links are the caller's own. + let permitted = context.isSystem === true || isLinkCreator(row, context); if (!permitted && this.canManageShares && row.object_name && row.record_id) { permitted = await this.canManageShares(String(row.object_name), String(row.record_id), context) .catch(() => false); @@ -632,19 +658,48 @@ export class ShareLinkService implements IShareLinkService { filter: ListShareLinksFilter, context: ExecutionContext, ): Promise { + // [#21328] ADR-0111's surface table rules this list SELF-SCOPED: a caller + // lists the links they created. Both doors (`share-link-routes.ts` and the + // runtime's `/share-links` domain) force `createdBy` to the caller for + // exactly that reason — but the read below ran under the caller's context, + // so it also demanded an object-level grant on `sys_share_link` that the + // platform's member baseline does not carry. Every plain member's list was + // refused, on every object, and the console's Share dialog (which loads + // this list on open) failed for all of them, while an admin's answered. + // + // So the caller's OWN list is read under the system context, and only it. + // The elevation fires when the creator filter IS the caller, by the creator + // rule — which already refuses a context with no user identity, so an + // identity-less caller never takes this path. Every other shape keeps + // today's read under the caller's context: no creator filter, a foreign + // creator, an empty or absent identity, and an admin listing someone + // else's links. (A system caller keeps its own bypass, as before.) + // + // Two things keep the system read narrow, and neither trusts the filter + // value: the `created_by` constraint is the caller's identity, written + // server-side, and every row it returns must pass `isLinkCreator` before it + // leaves — the same predicate `revokeLink` grants a creator's revoke with. + // The projection is the one this method has always returned: the engine + // strips both `internal` columns under any context, the token comes back + // through the privileged accessor below, and the password hash never does. + const selfScoped = + context.isSystem !== true && isLinkCreator({ created_by: filter.createdBy }, context); + const where: Record = {}; if (filter.object) where.object_name = filter.object; if (filter.recordId) where.record_id = filter.recordId; - if (filter.createdBy) where.created_by = filter.createdBy; + if (selfScoped) where.created_by = context.userId; + else if (filter.createdBy) where.created_by = filter.createdBy; if (!filter.includeRevoked) where.revoked_at = null; const rows = await this.engine.find('sys_share_link', { where, limit: 200, orderBy: [{ field: 'created_at', order: 'desc' }], - context: context.isSystem ? SYSTEM_CTX : context, + context: context.isSystem || selfScoped ? SYSTEM_CTX : context, } as any); - const links = Array.isArray(rows) ? (rows as ShareLink[]) : []; + const found = Array.isArray(rows) ? (rows as ShareLink[]) : []; + const links = selfScoped ? found.filter((link) => isLinkCreator(link, context)) : found; // [#21197] The caller's own links (the route forces `createdBy` to the // caller), and the console builds and copies each link's URL from its // token — so the tokens come back through the privileged accessor. The diff --git a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts index 388d0ac8f1f..39c59c89e86 100644 --- a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts +++ b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts @@ -151,7 +151,8 @@ describe('#21328: GET /share-links is self-scoped for a plain member', () => { const res = await stack.apiAs(aTok, 'GET', '/data/sys_share_link'); const text = await res.text(); expect(res.status, `the member must stay ungranted on sys_share_link: ${text}`).toBe(403); - expect(JSON.parse(text)).toMatchObject({ error: { code: 'PERMISSION_DENIED' } }); + // The data door's refusal carries `code` at the top level. + expect(JSON.parse(text)).toMatchObject({ code: 'PERMISSION_DENIED' }); }); it('[fixture] every link sits on the same record, so a record filter cannot separate them', async () => { From a1e1c11a1fc4c6ad0aa961bf06e79bb2a631f617 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:22:33 +0000 Subject: [PATCH 3/5] test(plugin-sharing): pin that the self-scoped read trusts no query predicate alone Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../src/share-link-service.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index 4cc4df8bcab..b8918dad579 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -538,6 +538,22 @@ describe('ShareLinkService', () => { expect(read.where.created_by, 'constrained server-side to the caller').toBe('alice'); }); + it('the system read trusts no query predicate alone: a dropped created_by constraint still leaks no foreign row', async () => { + // A driver that silently drops a `where` key answers with the WIDER set — + // under the system context, every creator's tokens. The rows that leave + // must still pass the creator rule. + const dropsCreator = { + ...engine, + async find(object: string, options?: any) { + const where = { ...(options?.where ?? {}) }; + delete where.created_by; + return engine.find(object, { ...options, where }); + }, + }; + const leaky = new ShareLinkService({ engine: dropsCreator as any }); + expect(listed(await leaky.listLinks({ ...ON_C1, createdBy: 'alice' }, { userId: 'alice' }))).toEqual(mine); + }); + it('through the record filter, other creators\' links on the same record are absent', async () => { const links = await svc.listLinks({ ...ON_C1, createdBy: 'alice' }, { userId: 'alice' }); expect(listed(links)).toEqual(mine); From dae1556a0abffc94fffcc0c5bfc444f3d419e06d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:34:09 +0000 Subject: [PATCH 4/5] fix(plugin-sharing): one isSystem read in listLinks; state the self-scoped list on the contract The elevation predicate no longer reads isSystem: a system caller asking for its own links gets the same rows either way, and listLinks keeps the single isSystem read site it had. The IShareLinkService.listLinks doc comment said every listing is read under context; it now states the self-scoped own list. Adds the persona pin to the route test and the changeset. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../21328-share-links-self-scoped-list.md | 17 +++++++++++++++++ .../src/share-link-service.test.ts | 2 ++ .../plugin-sharing/src/share-link-service.ts | 6 +++--- .../test/share-links-self-list.dogfood.test.ts | 15 +++++++++++++++ .../spec/src/contracts/share-link-service.ts | 7 +++++-- 5 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 .changeset/21328-share-links-self-scoped-list.md diff --git a/.changeset/21328-share-links-self-scoped-list.md b/.changeset/21328-share-links-self-scoped-list.md new file mode 100644 index 00000000000..cb9baeb6ca4 --- /dev/null +++ b/.changeset/21328-share-links-self-scoped-list.md @@ -0,0 +1,17 @@ +--- +"@objectstack/plugin-sharing": patch +"@objectstack/spec": patch +--- + +A plain member's share-link list now answers: `GET /api/v1/share-links` is self-scoped for every signed-in caller, as ADR-0111 rules it + +Clause-②: no + +`ShareLinkService.listLinks` read `sys_share_link` under the caller's context. Both share-link doors force the list's `createdBy` to the caller, but the read still needed an object-level grant on `sys_share_link`, and the platform's member baseline does not grant one. So every plain member's list was refused, with or without an object filter, and the Share dialog, which loads this list when it opens, showed an error for them on every record. An admin's list answered. + +- The caller's own list is now read under the system context. This happens only when the caller has a non-empty user identity and the creator filter equals it. The read is constrained server-side to that identity, and each row it returns must pass the creator rule before it leaves. +- One creator rule now serves both `listLinks` and `revokeLink`. It never matches a caller with no user identity. Neither HTTP door reaches that case, because both answer 401 first, so for an internal caller with no user identity, `revokeLink` now refuses a link whose `created_by` is absent or empty instead of treating it as theirs. +- Every other list shape keeps the caller's context, as before: no creator filter, another user as creator, no user identity, or an admin listing someone else's links. A system caller keeps its bypass. +- The rows carry the same columns as before. The token comes back so the console can build the link URL, and the password hash never does. +- `@objectstack/spec`: the `IShareLinkService.listLinks` doc comment now describes the self-scoped own list. It previously said every listing is read under `context`. This is a doc comment only, with no type or export change. +- ⛔ No permission set changes, and no new grant on `sys_share_link`. diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index b8918dad579..e4a1822e3ac 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -603,6 +603,8 @@ describe('ShareLinkService', () => { const links = await svc.listLinks({ object: 'ai_conversations' }, { isSystem: true }); expect(listed(links)).toEqual([...mine, ...theirs, ...admins, 'shl_empty', 'shl_omitted'].sort()); expect(shareLinkReads()[0].context.isSystem).toBe(true); + // A system caller asking for its own links gets the same rows either way. + expect(listed(await svc.listLinks({ createdBy: 'alice' }, { isSystem: true, userId: 'alice' }))).toEqual(mine); }); it('revoke and list read one creator rule: what you may revoke as creator is what your list shows', async () => { diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 51782f579dc..13f624a4e1a 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -673,7 +673,8 @@ export class ShareLinkService implements IShareLinkService { // identity-less caller never takes this path. Every other shape keeps // today's read under the caller's context: no creator filter, a foreign // creator, an empty or absent identity, and an admin listing someone - // else's links. (A system caller keeps its own bypass, as before.) + // else's links. A system caller keeps its own bypass, as before; asking + // for its own links returns the same rows either way. // // Two things keep the system read narrow, and neither trusts the filter // value: the `created_by` constraint is the caller's identity, written @@ -682,8 +683,7 @@ export class ShareLinkService implements IShareLinkService { // The projection is the one this method has always returned: the engine // strips both `internal` columns under any context, the token comes back // through the privileged accessor below, and the password hash never does. - const selfScoped = - context.isSystem !== true && isLinkCreator({ created_by: filter.createdBy }, context); + const selfScoped = isLinkCreator({ created_by: filter.createdBy }, context); const where: Record = {}; if (filter.object) where.object_name = filter.object; diff --git a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts index 39c59c89e86..e7edb460d46 100644 --- a/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts +++ b/packages/qa/dogfood/test/share-links-self-list.dogfood.test.ts @@ -147,6 +147,21 @@ describe('#21328: GET /share-links is self-scoped for a plain member', () => { // ── the fixture's own preconditions ─────────────────────────────────────── + it('[persona] the member resolves the platform baseline, is no admin, and is denied sys_share_link at object CRUD', async () => { + const res = await stack.apiAs(aTok, 'GET', '/security/explain?object=sys_share_link&operation=read'); + const text = await res.text(); + expect(res.status, text).toBe(200); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const layers: any[] = JSON.parse(text)?.layers ?? []; + const setNames = layers.flatMap((l) => l.contributors ?? []).map((c: { name?: unknown }) => String(c?.name)); + expect(setNames, 'a showcase member resolves the platform `member_default`').toContain('member_default'); + expect(setNames, 'the persona must not be an admin').not.toContain('admin_full_access'); + expect( + layers.find((l) => l.layer === 'object_crud')?.verdict, + 'no set the member holds grants read on sys_share_link — the fix must not depend on one', + ).toBe('denies'); + }); + it('[no-grant] the member holds no sys_share_link grant — the generic data door still refuses the table', async () => { const res = await stack.apiAs(aTok, 'GET', '/data/sys_share_link'); const text = await res.text(); diff --git a/packages/spec/src/contracts/share-link-service.ts b/packages/spec/src/contracts/share-link-service.ts index 6e46f699e57..4ab88390b73 100644 --- a/packages/spec/src/contracts/share-link-service.ts +++ b/packages/spec/src/contracts/share-link-service.ts @@ -236,8 +236,11 @@ export interface IShareLinkService { /** * List links for a record, an object, or a creator. * - * ENFORCEMENT PATH: the listing is read under `context`, so row visibility - * is decided by it. + * ENFORCEMENT PATH: the caller's OWN list — `createdBy` equal to the + * caller's non-empty `context.userId` — is self-scoped (ADR-0111): it needs + * no object-level grant on `sys_share_link` and returns only links the + * caller created, by the same creator rule `revokeLink` applies. Every other + * listing is read under `context`, so row visibility is decided by it. */ listLinks(filter: ListShareLinksFilter, context: ExecutionContext): Promise; From 8682b7b24115599b49aaba02fd05777ecc0c5a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 10:49:40 +0000 Subject: [PATCH 5/5] test(plugin-sharing): observe the list's envelope at the listLinks call The route's list is always the caller's own, which is now the self-scoped system read, so the envelope pin reads what the route hands listLinks (every resolver key) instead of the context of a read that no longer carries it. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude --- .../share-link-enforcement-context.test.ts | 37 ++++++++++++++++--- .../plugin-sharing/src/share-link-service.ts | 4 +- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/share-link-enforcement-context.test.ts b/packages/plugins/plugin-sharing/src/share-link-enforcement-context.test.ts index d4390a7da7e..4ea2b59b942 100644 --- a/packages/plugins/plugin-sharing/src/share-link-enforcement-context.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-enforcement-context.test.ts @@ -171,7 +171,10 @@ async function bootPlugin(opts: { session: () => any; tables: Record + (ctx.registerService.mock.calls as Array<[string, unknown]>).find(([n]) => n === name)?.[1]; + return { engine, http, registered }; } interface Captured { status: number; body: any } @@ -268,17 +271,41 @@ describe('[#6206] share-link routes hand ENFORCEMENT the whole authz envelope', expect(enforcementCtx.isSystem).toBe(false); }); - it('listLinks reads under the same whole envelope', async () => { + // [#21328] Observed at the CALL, not at the read. This pin used to read the + // envelope off the `sys_share_link` read, because that read ran under it. + // The route's list is always the caller's own (it forces `createdBy`), and + // ADR-0111 rules that list self-scoped, so `listLinks` now reads it under the + // system context, constrained to the caller — the read no longer carries the + // envelope, by design. What this file pins is unchanged: the route hands + // `listLinks` every key the resolver produced, so a shape that does read + // under the caller's context (another creator, no creator) gets all of it. + it('listLinks is handed the same whole envelope', async () => { const tables = fixtureTables(); - const { engine, http } = await bootPlugin({ session: signedIn, tables }); + const { engine, http, registered } = await bootPlugin({ session: signedIn, tables }); + const listLinks = vi.spyOn(registered('shareLinks'), 'listLinks'); const res = await drive(http, `GET ${BASE}`, { query: {} }); expect(res.status).toBe(200); + expect(listLinks).toHaveBeenCalledTimes(1); + const [filter, handed] = listLinks.mock.calls[0] as [any, any]; + expect(filter.createdBy, 'the route forces the caller\'s own id').toBe(USER); + + const seam: any = await bootRequestContext({ + userId: USER, + email: EMAIL, + activeOrganizationId: ORG, + }); + const dropped = Object.keys(seam).filter((k) => !(k in handed)); + expect(dropped, 'keys the route dropped on the way into listLinks').toEqual([]); + expect(handed.accessible_org_ids).toEqual([ORG]); + expect(handed.posture).toBe('MEMBER'); + expect(handed.isSystem).toBe(false); + + // …and the read itself is the self-scoped one. const listRead = engine.finds.filter((f) => f.object === 'sys_share_link').pop(); expect(listRead).toBeDefined(); - expect(listRead!.context.accessible_org_ids).toEqual([ORG]); - expect(listRead!.context.posture).toBe('MEMBER'); + expect(listRead!.context.isSystem).toBe(true); }); it('an unresolvable request is still anonymous → 401, and nothing is enforced', async () => { diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 13f624a4e1a..368144fb9b7 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -15,7 +15,9 @@ import type { * is the HTTP layer's 401 vocabulary and is deliberately not named in this * file: the contexts this file receives are forwarded into `engine.find`, where * `accessible_org_ids` (ADR-0105 D2), `posture` (ADR-0095 D2), `org_user_ids`, - * `systemPermissions` and `tabPermissions` are all read. + * `systemPermissions` and `tabPermissions` are all read. One read is exempt by + * ruling: the caller's OWN share-link list, which ADR-0111 rules self-scoped + * and `listLinks` reads under the system context (see `isLinkCreator`). */ import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { Expression } from '@objectstack/spec';