fix(plugin-sharing): a plain member's own share-link list is self-scoped (ADR-0111) - #21403
Conversation
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…redicate alone Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…coped 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 37ca7de54a5c4369209615168125af7fe83f9184 && git checkout 37ca7de54a5c4369209615168125af7fe83f9184
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 85986144c2ef6f379955137677c5cbfb00e194d2 8682b7b24115599b49aaba02fd05777ecc0c5a0d && git checkout -B drift-repro 85986144c2ef6f379955137677c5cbfb00e194d2 && git merge --no-ff 8682b7b24115599b49aaba02fd05777ecc0c5a0d
node scripts/docs-audit/affected-docs.mjs --json 85986144c2ef6f379955137677c5cbfb00e194d2
|
Fixes #21328
Clause-②: no
What was wrong
GET /api/v1/share-linksrefused every plain member: a caller holdingmember_defaultand nosys_share_linkgrant was refused with no filter, with an object filter, and with any record. The console's Share dialog loads this list when it opens, so it failed for that whole class of user on every record. Both share-link doors already forcecreatedByto the caller, because ADR-0111's surface table rules the list self-scoped. ButShareLinkService.listLinksreadsys_share_linkunder the caller's context, and that read needs an object-level grant the member baseline does not carry. An admin's list answered.Measured through this PR's route pin (showcase boot via
@objectstack/verify). At the merge baseee75aae1a, and again with the elevation ablated at8682b7b24, the plain member's six list cases answered 500 with codePERMISSION_DENIED. The admin's list answered 200 with exactly the admin's own link. The Acceptance notes explain why this composition answers 500 where the card's hosted composition answered 403.The change (the triage direction quoted on the card)
isLinkCreator(row, context)inshare-link-service.tsholds when the caller has a non-empty user identity and the row'screated_byequals it.revokeLinkandlistLinksboth read this rule, and there is no second creator test.listLinks. The read runs under the system context only when the creator filter passes that rule, which means the filter names the caller. In that case thecreated_byconstraint is the caller's identity, written server-side, and each returned row must passisLinkCreatorbefore it leaves. Every other shape keeps the caller's context: no creator filter, another user as creator, an empty or absent identity, and an admin listing someone else's links. A system caller keeps its bypass.listLinkskeeps its singleisSystemread, so the system-context census stays at 114.internalcolumns under any context. The token comes back through the existing privileged accessor, and the password hash never does (route pin[secret]).sys_share_linkgrant (route pins[persona]and[no-grant]).What this deliberately changes beyond the list
revokeLink's creator check now refuses a caller with no user identity. Before,undefined === undefinedlet an identity-less, non-system caller revoke a link whosecreated_bythe driver omitted. Neither HTTP door reaches that case, because both answer 401 first. The new behaviour is pinned fail-closed. The creator, non-creator and ADR-0111 D8 record-manager paths are unchanged, and their existing pins pass untouched.share-link-enforcement-context.test.ts. The pinlistLinks reads under the same whole enveloperead the envelope off thesys_share_linkread. The route's list is always the caller's own, and that list is now the system read, so the pin now observes the envelope at thelistLinkscall. It checks that every key the resolver produced arrives, using the same comparison as thecreateLinkpin, and asserts that the read is the self-scoped one. The property the file pins is unchanged: the route hands enforcement the whole envelope (ruling A, commit 8e13ca8).@objectstack/spec. TheIShareLinkService.listLinksdoc comment said every listing is read undercontext. It now describes the self-scoped own list. This is a doc comment only.Tests (head
8682b7b24)Route pin, real composition.
packages/qa/dogfood/test/share-links-self-list.dogfood.test.tshas 11 cases, and all 11 pass. A plain member lists exactly their own links, both with no filter and with the Share dialog's object and record filter. Member B's links and the admin's sit on the same record and stay absent. AcreatedByquery naming B is ignored. A plain member with no links gets[]. The row carries its token and nopassword_hash. The admin's list is unchanged, and an anonymous caller gets 401UNAUTHENTICATED.Service pins.
share-link-service.test.tsandshare-link-enforcement-context.test.tspass 52 of 52. The service pins cover:PERMISSION_DENIED/ 403;created_bypredicate, which still leaks no foreign row;Package runs.
pnpm --filter @objectstack/plugin-sharing testpasses 928 tests in 38 files, and itstypecheckis OK.@objectstack/specpassestypecheck, and itstestpasses 17529 tests in 598 files.@objectstack/dogfoodpassestypecheck, and its tsc program includes the new test.Ablations, service level. Each leg ran from the committed head through
scripts/ablation-replace.mjs, and each restore was proven to leave the blob equal to HEAD withgit diff HEADempty.isLinkCreatorwhereand creator post-filter)whereonly removedAblation, route level. The elevation was removed and plugin-sharing rebuilt.
ablation-dist-preflightfound the marker in 2 dist files. Result: 6 red (the own, empty, secret and foreign cases, at 500PERMISSION_DENIED) and 5 green (persona, no-grant, fixture, admin, anonymous, which the test header says sit still). For the restore leg, plugin-sharing was rebuilt, the marker was absent from dist, and all 11 passed. The first route mutation (afalse &&guard) was constant-folded out of dist. The preflight caught that before any test ran, so it measured nothing, and the leg was redone with a marker the bundler keeps.Gates.
dispatch-gates --commandsat this head derives 89 commands, and all exit 0.check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET, because unrelated packages had not been built). Those packages were built from the turbo cache, and the rerun exited 0. The--ranreconciliation reports 89 derived, 89 run and 0 NOT MEASURED, a figure derived from the recorded exit codes.Lint (narrowed). eslint
--no-inline-configover the 5 changed TS files reports 0 errors and 0 warnings in all 5 files, and the config resolves for each. The narrowing is safe because the config enables no type-aware rules, and its local plugins read only their baselines. So this diff cannot move the verdict on any untouched file.Docs
content/docs/**(outsidereleases/andreferences/) andskills/**hold no sentence this change makes false. It makes true ADR-0111's surface-table entry for share links, "list is self-scoped", for every signed-in caller.Acceptance notes
registerShareLinkRoutesserves/api/v1/share-links, not the runtime/share-linksdomain. The error status shows it: that door answers 500 where the dispatcher's mapper answers 403. The runtime domain forwardscreatedByas the caller's id and the full envelope to the samelistLinks, so the service fix covers both doors. The runtime door has no separate route pin here.PERMISSION_DENIEDrefusal (which carriesstatusCode403 and nostatus) to HTTP 500. It is still reachable after this PR:POST /api/v1/share-linksby a plain member, on an object they cannot read, answered 500 with codePERMISSION_DENIEDon the showcase boot.revokeLink's creator read already does. Share-link rows are inserted under the system context withorganization_idnull (measured on the showcase boot). A caller's own list therefore spans organizations in the environment's database, the same reachrevokeLinkalready gives the creator. This follows the ruled direction. It is an observation only, with no wrong answer measured.scripts/adr-anchorsentry forshare-link-service.ts. The ADR-0111 id stays in the code. An anchor would be outside the claimed surface, and no one is set to carry it.origin/mainhas moved since the merge base. None of the upstream changes touch this PR's six files.Generated by Claude Code