fix(plugin-sharing): the record owner and an explicit Modify-All holder may mint a share link without visibility (ADR-0111 D8 rule 1, ruling A′) - #21447
Conversation
…ct at the dispatcher door The ruled matrix (ADR-0111 D8 rule 1, ruling A'): the owner mints on an owner-private object, a hierarchy manager without visibility is refused, a non-owner member is refused, Modify-All mints, and an anonymous holder resolves the owner's link. Booted on the real SecurityPlugin and the real SharingServicePlugin (registerShareLinkRoutes: false). Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…er may mint a share link without visibility (ADR-0111 D8 rule 1, ruling A') createLink admits visibility OR owner OR Modify-All bypass, behind the publicSharing opt-in and before eligibility. The owner and bypass halves are canManageShares' own first two branches (ownerOrBypass), exposed as SharingService.canMintWithoutVisibility without the hierarchy-depth branch, and withheld where an organization wall is in force. The plugin wires the probe into ShareLinkService beside canManageShares. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…e the service and the owner alternative at the organization wall Beside the service: the owner mints on an owner-private object, a hierarchy manager without visibility is refused (and its write scope is never asked), a non-owner member is refused, Modify-All mints with visibility refused; the opt-in comes first and eligibility last. Through the real plugin wiring and the real Layer 0: a member who owns a record in an organization they are not in is refused under group and isolated. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…ure sentence; who may mint in the share-link docs; changeset Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…are-link-mint-authority # Conflicts: # packages/runtime/src/domains/share-links-enforcement-context.test.ts
…sSystem reads A system caller's read runs under the system context, and the probe admits only on a row it re-reads the same way, so the two added checks bought nothing and moved the system-context census. Pinned: a system caller keeps its 404 for a missing record with the probe wired. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
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 5be3cb898d8d15b3d61a7b308a544b914e731fb6 && git checkout 5be3cb898d8d15b3d61a7b308a544b914e731fb6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 1031133472b9544fed48d15ee6ee3be419c00809 && git checkout -B drift-repro 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65 && git merge --no-ff 1031133472b9544fed48d15ee6ee3be419c00809
node scripts/docs-audit/affected-docs.mjs --json 53fd35e3e3a0b18b790ab79bd2c65f353e11ab65
|
… owner and Modify-All mint alternatives do not pass canMintWithoutVisibility answers false when the ADR-0066 D3 capability AND-gate refuses the caller a read, read from the declared required_permissions layer of ISecurityService.explain; createLink then re-throws the capability refusal as it came. Positive evidence is needed to admit; a probe without explain, a throw or a report without the layer stop. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…h the capable-owner control Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
….createLink and ISharingService.canManageShares TSDoc; the capability hard stop in D8 rule 1; the Consequences line on hierarchy managers; spec patch in the changeset TSDoc only in packages/spec: no schema, key, type or export changes. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
…rries it Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
维护者速读(终稿)
改了什么:私有对象上的记录,主人现在可以自己生成分享链接;持有"全部修改"(Modify-All)权限的人也可以。
为什么改:AI 会话这类只有主人能看的对象,数据接口连主人自己都挡在外面,普通成员分享自己的会话一直是 403。10 月 2 日总监席的裁决 A′(您回复「同意」)定了这个口径。 风险与代价(含回滚):本 PR 比裁决字面多收紧了两处,都是席位有意定的,请一并确认。
席位意见:建议批准。
你要做的:确认上面两处收紧,然后批准本 PR(Approve)。批准后席位负责落地,不需要您再操作。 Generated by Claude Code · https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ |
Fixes #21329
Clause-②: yes (widening)
Ruling
5950188467(A′):createLinkadmits visibility OR the record owner OR an explicit Modify-All bypass, behind thepublicSharingopt-in and beforeeligibility. A hierarchy manager still needs visibility to mint. ADR-0111 D8 rule 1 is restated to match. This PR is governed (Tier H,docs/adr/**) and stays draft for the maintainer's own approval.It carries two deliberate narrowings of the ruled letter, each answered by the seat in claim revision 1 on #21329 and each a named part of the approval: the organization wall and a required capability. Where either applies, visibility alone admits, as before this PR. Neither is ever wider than
mainplus the two ruled alternatives. Both are described below.Measured on
main(bdd3654f2), before the changeDriven in-process through the runtime dispatcher's
/share-linksdomain. The stack is the realSecurityPluginand the realSharingServicePlugin(registerShareLinkRoutes: false),singleposture. The object is an analogue of the conversation object:access.default: 'private'(no wildcard grant covers it),publicSharingon, anowner_idthe owner holds.POST /share-linksPERMISSION_DENIED(the CRUD gate refuses the owner's own read)PERMISSION_DENIEDunitcovering the owner, no read grant (canManageSharesanswers true for them)PERMISSION_DENIEDPR #21429 had not landed at
bdd3654f2, so the plugin route door was not measured there. It has landed since, this branch merges it, and the pins below read both doors.What changed
SharingService(sharing-service.ts).canManageShares' owner and Modify-All branches move, unchanged, into one private helperownerOrBypass. It returnsadmit,refuseorundecided, andundecidedcarries the owner value.canManageSharesreads it, then runs its DEPTH branch exactly as before. The new publiccanMintWithoutVisibility(object, recordId, context)reads the same helper. No second notion of ownership is written.canMintWithoutVisibilitynever callsresolveWriteScopeor the hierarchy resolver. It is a different method over the shared helper, notcanManageShareswith a branch that happens to say no. Pinned at both levels: the manager is a share-manager (canManageSharesis true and their revoke succeeds), their mint is refused, and the write-scope probe's call count does not move during the mint.ShareLinkService.createLink(share-link-service.ts). The order is: thepublicSharingopt-in, then the request-shape checks (permission, audience, email allowlist), then authority, theneligibility, then expiry. Authority means visibility first. Only when that read refuses does the service ask the late-boundcanMintWithoutVisibilityoption. When it admits, the row is read under the system context, so eligibility judges the row an anonymous holder would be served. A refusal the visibility read throws is kept and re-thrown unless the probe admits, so every caller refused before this PR, and every caller a narrowing stops, gets the same envelope it got onmain. A throwing probe is a refusal. A host that buildsShareLinkServicewithout the option keeps visibility alone.SharingServicePlugin(sharing-plugin.ts, one hunk). It wirescanMintWithoutVisibilityinto the link service besidecanManageShares.packages/spec, TSDoc only.IShareLinkService.createLinknow states who may mint. It used to say "you may only link-share a record you can yourself see".ISharingService.canManageSharesnow describes its hierarchy-manager branch, which is implemented, and says that branch is not mint authority. No schema, key, type or export changes.Refusal envelopes, in order:
SHARING_NOT_ENABLEDPERMISSION_NOT_ALLOWED/AUDIENCE_NOT_ALLOWED, 400VALIDATION_FAILEDPERMISSION_DENIED, 403 at both doorsFORBIDDEN; a system caller on a missing record gets 404RECORD_NOT_FOUNDRECORD_NOT_ELIGIBLE/ELIGIBILITY_UNEVALUABLENarrowing 1: the organization wall
Under the
groupandisolatedpostures (ADR-0105 D1),canMintWithoutVisibilityanswers false and visibility alone admits, as onmain. The visibility read is what applies Layer 0, and neither alternative knows the record's organization. The owner column outlives a membership: a member who left an organization still owns the rows they created there. The Modify-All probe (hasWriteBypass) is object-wide.Without this narrowing, the owner alternative carries a mint across the wall. Measured through the real
SharingServicePluginwiring and the realcomputeTenantLayer0Filter: a member of plant B who owns a record in plant A gets 201, and a link lands on plant A's record. That is ablation A4 below, under bothgroupandisolated. The posture is the oneorganizationScopeRequired()already reads, and it fails closed: an unresolvable posture counts as walled. Cloud runs one database per environment (ADR-0095), sosingle, and the card's own case is served in full. Revoke authority is untouched by the wall.Narrowing 2: a required capability
When the visibility read was refused because the caller lacks a capability the object requires (
requiredPermissions, ADR-0066 D3), that refusal is a hard stop. Neither alternative applies past it, and the caller gets the capability refusal itself. The owner exception is about the record row, not about a capability an administrator withheld for the whole object.The premise, measured before building. The premise was that this refusal is distinguishable from the owner-private CRUD refusal by a signal that already exists and is declared. It is, through
ISecurityService.explain:explainis a declared, non-optional contract method, reached through the samesecurityservice handle the sharing service already probes.ExplainDecisionSchema) with arequired_permissionslayer and adeniesverdict.requiredPermissionsnormalisation, the same held-capability union and the same ADR-0090 D10 delegator intersection.required_permissions: not_applicableandobject_crud: denies.The refusal's thrown shape is not usable:
PermissionDeniedError.detailsis typed as an open record (string keys, unknown values), the spec error envelope typesdetailsasz.unknown(), andmissingPermissionsis declared nowhere. No new contract method is added. The sharing side's own structural slice,SharingSecurityProbe, gains an optionalexplainfor the existing method.Fail-closed. Admitting needs positive evidence: the layer present with
neutral(capabilities held) ornot_applicable(none required). A security service withoutexplain, a throw, a report missing the layer, or any other verdict is a stop. The one exception is a deployment with no security service at all, where nothing enforces a capability gate.explainruns only for a principal an alternative already admitted, so a refused stranger never pays for it.File surface
This follows claim revision 1:
share-link-service.tsandsharing-service.ts;sharing-plugin.ts(one hunk, accepted);packages/spec/src/contracts/share-link-service.tsandsharing-service.ts(cross-lanedomain:spec);Not touched: permission sets, and any spec shape, key or export.
Pins
plugin-sharing/src/share-link-service.test.ts, 26 cases in the A′ block). A realSharingServiceis wired as the plugin wires it, with probe doubles. The doubles give the security probe anexplainwhoserequired_permissionslayer is computed from the same held-capability table the read double refuses with.canManageSharesis true and the write scope is never asked; a non-owner member is refused; Modify-All mints with its visibility read refused.FORBIDDEN; the opt-in comes before the probe; eligibility comes after the owner is admitted; a caller with no authority is refused before eligibility.isolated,group, an unresolvable posture), a deployment without the probe, a throwing probe, and a system caller's 404.details.missingPermissionson the rejection), nothing lands, andcanManageSharesis still true for them. A Modify-All holder lacking it is refused the same way. Two controls mint: an owner who holds the capability and is refused only by the CRUD grant, and the owner of an object that requires no capability. A refused stranger never callsexplain. Four fail-closed probe shapes stop the owner. With no security service at all, the alternative stands.runtime/src/domains/share-links-enforcement-context.test.ts, the[#21329]block, 8 cases). The realSecurityPluginand the realSharingServicePlugin(registerShareLinkRoutes: false) compose the service. The dispatcher'shandleShareLinksRequestand the plugin'sregisterShareLinkRoutesboth drive it.PERMISSION_DENIEDat both doors, and nothing lands.missingPermissions: ['view_vault']), and the capable owner's only at the CRUD grant. The owner lacking the capability gets 403PERMISSION_DENIEDat both doors, with the refusal's own message, and nothing lands. The control: the capable owner mints at both doors. The wire carries the refusal's user-facing sentence only, so which gate refused is pinned beside the service.plugin-security/src/share-link-tenant-wall.test.ts, 3 cases). A cross-organization owner is refused 403FORBIDDENundergroupandisolated, with the store read back empty. The control: the same owner mints in their own organization.Ablations (
sharing-service.ts, each from a committed head throughscripts/ablation-replace.mjs)src)dist)dist)6bf7f10f6)canManageShares(owner)control[owner], and[manager], whose revoke control mints as the ownercanMintWithoutVisibilityaskscanManageSharesin place ofownerOrBypass(the hierarchy branch let in; at6bf7f10f6and again at103113347on the rewritten line)[manager], 201 where 403 was expected6bf7f10f6)6bf7f10f6)isolated,group, an unresolvable posture103113347)[capability], where the owner gets 201 onvault_ownerdistlegs rebuiltplugin-sharing.scripts/ablation-dist-preflight.mjsfound each marker present before the run.git diff HEADwas empty on every leg. A rebuild, then the preflight with--absent, exited 0 for every marker, with the tree clean, and the suites were green again.103113347, with the same direction. The lines A1, A3 and A4 mutate are byte-identical at103113347.Tests (head
103113347)plugin-sharing: typecheck OK (the test layer holds 2 files / 3 errors / 3 signatures, held).test: 38 files, 954 passed.plugin-security: typecheck OK.test: 162 files, 3519 passed, 45 skipped.runtime: typecheck OK (the test layer holds 27 files / 190 errors / 68 signatures, held).share-links-enforcement-contextandshare-links-internal-hash-probe: 2 files, 29 passed.speccontracts/sharing-service.test.ts(which pinscanManageShares' doc namingmodifyAllRecords) andcontracts/share-link-service.test.ts: 2 files, 17 passed.dispatch-gates --commandsderives 118 commands, including the spec families for the two contract files (check:api-surface,check:docs,check:export-origins,check:skill-refsand others). Every one exits 0, and--ranreconciles 118 derived, 118 run, 0 NOT-MEASURED.check:dual-build-cjs-loadsfirst exited 3 on this fresh worktree's unbuilt packages; after a full build it exited 0.eslint --no-inline-config --format jsonover the 8 changed TS files reports 8 files, 0 errors, 0 warnings. The config resolves for each file (5 to 6 rules).eslint.config.mjsenables no type-aware linting (its own note, lines 327-328), so this diff cannot move a verdict on an untouched file. The fullpnpm lintis CI's.share-links-self-list,showcase-client-liaison-fixturesandaudit-log-internal-fieldspassed at41f8cf30c(3 files, 19 passed). Not rerun at this head; CI's Dogfood Regression Gate runs them.Changeset
.changeset/21329-share-link-owner-mint.mdgrades@objectstack/plugin-sharingminor(the widening) and@objectstack/specpatch. The spec grade follows two rules:packages/spec/dist/contracts/index.d.ts. So it is a published change, and AGENTS.md (Post-Task Checklist, step 3) gives a fix-class change in a released packagepatch.check-changeset-no-major's level axis makes theClause-②: yesminimum PR-scoped ("at least one" moved package atminor+), andplugin-sharingcarries it.Docs
content/docs/protocol/objectql/security.mdx, "Who may mint and revoke": the three alternatives, the hierarchy-manager exclusion, both narrowings and the order. Its revoke parenthetical now names the hierarchy manager, ascanManageShareshas admitted since the DEPTH extension.skills/**holds no sentence about mint authority.Acceptance notes
PERMISSION_DENIED, the same user-facing sentence, no capability names. That is by design, sincedetailscarries the developer half. So which gate refused is pinned at the service, on the rejection itself.6bf7f10f6holds the conflict resolution in the runtime test. Both describe blocks are kept, and the landed plugin-door helpermintOnPluginDoortakes an optional body whose default is the one it always sent.维护者速读(草稿)
改了什么:私有对象上的记录,主人现在可以自己生成分享链接;拥有“全部修改”权限的管理员也可以。仍然必须先在对象上开启公开分享,资格条件也照旧检查。部门上级虽然能撤销下属记录上的链接,但如果看不到这条记录,仍然不能生成新链接。规范里对应的两段接口说明也一起改了,只改说明文字,不改任何字段。
为什么改:AI 会话这类“只有主人能看”的对象,数据接口连主人自己也挡在外面,所以普通成员一直分享不了自己的会话(403)。您 10 月 2 日的裁决(A′)定下这个口径,ADR-0111 D8 第 1 条随之改写。
风险与代价(含回滚):比裁决字面多收紧了两处,都是有意为之,请一并确认。
两处之外的行为与改动前一致;云上一个环境一个库,主人分享会话不受影响。回滚即还原本 PR,行为退回只看可见性。
席位意见:
你要做的:确认上面两处收紧,然后批准本 PR。
Generated by Claude Code