Commit 4c8363f
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)
Fixes #21329
Clause-②: yes (widening)
Ruling `5950188467` (A′): `createLink` admits **visibility OR the record
owner OR an explicit Modify-All bypass**, behind the `publicSharing`
opt-in and before `eligibility`. 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 `main` plus the two ruled
alternatives. Both are described below.
## Measured on `main` (`bdd3654f2`), before the change
Driven in-process through the runtime dispatcher's `/share-links`
domain. The stack is the real `SecurityPlugin` and the real
`SharingServicePlugin` (`registerShareLinkRoutes: false`), `single`
posture. The object is an analogue of the conversation object:
`access.default: 'private'` (no wildcard grant covers it),
`publicSharing` on, an `owner_id` the owner holds.
| caller | `POST /share-links` |
|---|---|
| the owner (member baseline only) | **403 `PERMISSION_DENIED`** (the
CRUD gate refuses the owner's own read) |
| a non-owner member | 403 `PERMISSION_DENIED` |
| a Modify-All holder | 201 |
| a hierarchy manager: write depth `unit` covering the owner, no read
grant (`canManageShares` answers **true** for them) | 403
`PERMISSION_DENIED` |
| anonymous resolve of a minted link (the Modify-All holder's) | 200,
record served |
PR #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 helper
`ownerOrBypass`. It returns `admit`, `refuse` or `undecided`, and
`undecided` carries the owner value. `canManageShares` reads it, then
runs its DEPTH branch exactly as before. The new public
`canMintWithoutVisibility(object, recordId, context)` reads the same
helper. No second notion of ownership is written.
- **How the hierarchy branch is kept out of minting.**
`canMintWithoutVisibility` never calls `resolveWriteScope` or the
hierarchy resolver. It is a different method over the shared helper, not
`canManageShares` with a branch that happens to say no. Pinned at both
levels: the manager is a share-manager (`canManageShares` is 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: the `publicSharing` opt-in, then the request-shape checks
(permission, audience, email allowlist), then authority, then
`eligibility`, then expiry. Authority means visibility first. Only when
that read refuses does the service ask the late-bound
`canMintWithoutVisibility` option. 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 on
`main`. A throwing probe is a refusal. A host that builds
`ShareLinkService` without the option keeps visibility alone.
- **`SharingServicePlugin`** (`sharing-plugin.ts`, one hunk). It wires
`canMintWithoutVisibility` into the link service beside
`canManageShares`.
- **`packages/spec`, TSDoc only.** `IShareLinkService.createLink` now
states who may mint. It used to say "you may only link-share a record
you can yourself see". `ISharingService.canManageShares` now 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:
| step | refusal |
|---|---|
| opt-in | 422 `SHARING_NOT_ENABLED` |
| request shape | 422 `PERMISSION_NOT_ALLOWED` / `AUDIENCE_NOT_ALLOWED`,
400 `VALIDATION_FAILED` |
| authority, the read threw (CRUD grant, or the capability gate) | the
gate's own refusal re-thrown: `PERMISSION_DENIED`, 403 at both doors |
| authority, the read was empty | 403 `FORBIDDEN`; a system caller on a
missing record gets 404 `RECORD_NOT_FOUND` |
| eligibility | 422 `RECORD_NOT_ELIGIBLE` / `ELIGIBILITY_UNEVALUABLE` |
## Narrowing 1: the organization wall
Under the `group` and `isolated` postures (ADR-0105 D1),
`canMintWithoutVisibility` answers false and visibility alone admits, as
on `main`. 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 `SharingServicePlugin` wiring and the
real `computeTenantLayer0Filter`: 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 both `group` and `isolated`. The posture is the
one `organizationScopeRequired()` already reads, and it fails closed: an
unresolvable posture counts as walled. Cloud runs one database per
environment (ADR-0095), so `single`, 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`:
- `explain` is a declared, non-optional contract method, reached through
the same `security` service handle the sharing service already probes.
- Its report is declared in spec (`ExplainDecisionSchema`) with a
`required_permissions` layer and a `denies` verdict.
- The explain engine computes that layer with the read gate's own
capability fold: the same `requiredPermissions` normalisation, the same
held-capability union and the same ADR-0090 D10 delegator intersection.
- On an owner-private object that requires no capability, the same
report gives `required_permissions: not_applicable` and `object_crud:
denies`.
The refusal's thrown shape is not usable:
`PermissionDeniedError.details` is typed as an open record (string keys,
unknown values), the spec error envelope types `details` as
`z.unknown()`, and `missingPermissions` is declared nowhere. No new
contract method is added. The sharing side's own structural slice,
`SharingSecurityProbe`, gains an optional `explain` for the existing
method.
**Fail-closed.** Admitting needs positive evidence: the layer present
with `neutral` (capabilities held) or `not_applicable` (none required).
A security service without `explain`, 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. `explain` runs 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.ts` and `sharing-service.ts`;
- `sharing-plugin.ts` (one hunk, accepted);
- the TSDoc-only hunks in
`packages/spec/src/contracts/share-link-service.ts` and
`sharing-service.ts` (cross-lane `domain:spec`);
- the pins, the changeset, ADR-0111 (D8, its status line, and the
Consequences line on hierarchy managers) and the share-link docs
paragraph.
Not touched: permission sets, and any spec shape, key or export.
## Pins
- **Beside the service**
(`plugin-sharing/src/share-link-service.test.ts`, 26 cases in the A′
block). A real `SharingService` is wired as the plugin wires it, with
probe doubles. The doubles give the security probe an `explain` whose
`required_permissions` layer is computed from the same held-capability
table the read double refuses with.
- The four ruled cases: the owner mints and the link resolves; the
hierarchy manager is refused, with a control that `canManageShares` is
true and the write scope is never asked; a non-owner member is refused;
Modify-All mints with its visibility read refused.
- Ordering and envelopes: visibility admits without asking the probe; an
empty read stays 403 `FORBIDDEN`; the opt-in comes before the probe;
eligibility comes after the owner is admitted; a caller with no
authority is refused before eligibility.
- The wall (`isolated`, `group`, an unresolvable posture), a deployment
without the probe, a throwing probe, and a system caller's 404.
- **The capability stop.** An owner lacking the capability is refused
with the capability refusal itself (`details.missingPermissions` on the
rejection), nothing lands, and `canManageShares` is 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 calls `explain`. Four fail-closed probe shapes stop the
owner. With no security service at all, the alternative stands.
- **At both doors**
(`runtime/src/domains/share-links-enforcement-context.test.ts`, the
`[#21329]` block, 8 cases). The real `SecurityPlugin` and the real
`SharingServicePlugin` (`registerShareLinkRoutes: false`) compose the
service. The dispatcher's `handleShareLinksRequest` and the plugin's
`registerShareLinkRoutes` both drive it.
- The owner gets 201 at both doors, and the anonymous resolve answers
200.
- The stranger gets 403 `PERMISSION_DENIED` at both doors, and nothing
lands.
- Modify-All gets 201 at both doors.
- The manager gets 403 at both doors, nothing lands, the write scope is
not asked, and their revoke of the owner's link answers 200.
- **On a capability-gated, owner-private object.** A persona control:
the owner's read is refused at the capability gate (`missingPermissions:
['view_vault']`), and the capable owner's only at the CRUD grant. The
owner lacking the capability gets 403 `PERMISSION_DENIED` at 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.
- **At the organization wall**
(`plugin-security/src/share-link-tenant-wall.test.ts`, 3 cases). A
cross-organization owner is refused 403 `FORBIDDEN` under `group` and
`isolated`, 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 through
`scripts/ablation-replace.mjs`)
| mutation | service suite (`src`) | both doors (`dist`) | wall suite
(`dist`) |
|---|---|---|---|
| A1 owner branch never admits (at `6bf7f10f6`) | **5 red**: the owner
pins, plus the wall cases' `canManageShares(owner)` control | **2 red**:
`[owner]`, and `[manager]`, whose revoke control mints as the owner |
green |
| A2 `canMintWithoutVisibility` asks `canManageShares` in place of
`ownerOrBypass` (the hierarchy branch let in; at `6bf7f10f6` and again
at `103113347` on the rewritten line) | **1 red** both times: the
manager mints | **1 red** both times: `[manager]`, 201 where 403 was
expected | green |
| A3 bypass branch never admits (at `6bf7f10f6`) | **1 red**: Modify-All
with visibility refused | green: the Modify-All holder reads the private
object | green |
| A4 wall guard removed (at `6bf7f10f6`) | **3 red**: `isolated`,
`group`, an unresolvable posture | green | **2 red**: the
cross-organization owner gets 201 |
| A5 capability stop removed (at `103113347`) | **6 red**: the owner and
the Modify-All holder lacking the capability, and the four fail-closed
shapes | **1 red**: `[capability]`, where the owner gets 201 on
`vault_owner` | green |
- The `dist` legs rebuilt `plugin-sharing`.
`scripts/ablation-dist-preflight.mjs` found each marker present before
the run.
- Restore: `git diff HEAD` was 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.
- The first A1 attempt did not run. Its marker was stripped by the
bundler, so the reading was void, and it was redone with a marker that
survives the build.
- In A5 the build's DTS pass failed (TS6133: the stop's helper becomes
unused). The JS pass the suites consume emitted, and the preflight found
the marker in both built files.
- The round-2 commits rewrote the line A2 mutates, so A2 was rerun on
the new line at `103113347`, with the same direction. The lines A1, A3
and A4 mutate are byte-identical at `103113347`.
## 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-context` and
`share-links-internal-hash-probe`: 2 files, 29 passed.
- `spec` `contracts/sharing-service.test.ts` (which pins
`canManageShares`' doc naming `modifyAllRecords`) and
`contracts/share-link-service.test.ts`: 2 files, 17 passed.
- Gates: `dispatch-gates --commands` derives 118 commands, including the
spec families for the two contract files (`check:api-surface`,
`check:docs`, `check:export-origins`, `check:skill-refs` and others).
Every one exits 0, and `--ran` reconciles 118 derived, 118 run, 0
NOT-MEASURED. `check:dual-build-cjs-loads` first exited 3 on this fresh
worktree's unbuilt packages; after a full build it exited 0.
- Lint, narrowed: `eslint --no-inline-config --format json` over the 8
changed TS files reports 8 files, 0 errors, 0 warnings. The config
resolves for each file (5 to 6 rules). `eslint.config.mjs` enables no
type-aware linting (its own note, lines 327-328), so this diff cannot
move a verdict on an untouched file. The full `pnpm lint` is CI's.
- Dogfood `share-links-self-list`, `showcase-client-liaison-fixtures`
and `audit-log-internal-fields` passed at `41f8cf30c` (3 files, 19
passed). Not rerun at this head; CI's Dogfood Regression Gate runs them.
## Changeset
`.changeset/21329-share-link-owner-mint.md` grades
`@objectstack/plugin-sharing` `minor` (the widening) and
`@objectstack/spec` `patch`. The spec grade follows two rules:
- The TSDoc ships: measured, the new sentences are in
`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 package `patch`.
- `check-changeset-no-major`'s level axis makes the `Clause-②: yes`
minimum PR-scoped ("at least one" moved package at `minor`+), and
`plugin-sharing` carries 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, as `canManageShares` has admitted since the DEPTH
extension.
- `skills/**` holds no sentence about mint authority.
- ADR-0111:
- D8 rule 1 is restated as ruled, with the ruling id. Both narrowings
are recorded as named parts of the approval.
- The D-future sentence now says the tightening stays recorded and
untaken.
- The status line names both narrowings.
- The Consequences line on hierarchy managers now says DEPTH landed,
depends on the enterprise resolver, and is not mint authority.
## Acceptance notes
- Both refusal kinds look the same on the wire: 403 `PERMISSION_DENIED`,
the same user-facing sentence, no capability names. That is by design,
since `details` carries the developer half. So which gate refused is
pinned at the service, on the rejection itself.
- The merge commit `6bf7f10f6` holds the conflict resolution in the
runtime test. Both describe blocks are kept, and the landed plugin-door
helper `mintOnPluginDoor` takes an optional body whose default is the
one it always sent.
## 维护者速读(草稿)
**改了什么**:私有对象上的记录,主人现在可以自己生成分享链接;拥有“全部修改”权限的管理员也可以。仍然必须先在对象上开启公开分享,资格条件也照旧检查。部门上级虽然能撤销下属记录上的链接,但如果看不到这条记录,仍然不能生成新链接。规范里对应的两段接口说明也一起改了,只改说明文字,不改任何字段。
**为什么改**:AI 会话这类“只有主人能看”的对象,数据接口连主人自己也挡在外面,所以普通成员一直分享不了自己的会话(403)。您 10 月
2 日的裁决(A′)定下这个口径,ADR-0111 D8 第 1 条随之改写。
**风险与代价(含回滚)**:比裁决字面多收紧了两处,都是有意为之,请一并确认。
- 多组织共库部署(group /
isolated)下,两条新通道关闭,只看可见性。否则已离开组织的成员能把旧组织里自己的记录做成公开链接,已实测会泄露。
- 对象要求某项能力而调用者没有时,两条新通道同样不放行。管理员收回了能力,主人就不能绕过去。
两处之外的行为与改动前一致;云上一个环境一个库,主人分享会话不受影响。回滚即还原本 PR,行为退回只看可见性。
**席位意见**:
**你要做的**:确认上面两处收紧,然后批准本 PR。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 6210f88 commit 4c8363f
11 files changed
Lines changed: 1104 additions & 73 deletions
File tree
- .changeset
- content/docs/protocol/objectql
- docs/adr
- packages
- plugins
- plugin-security/src
- plugin-sharing/src
- runtime/src/domains
- spec/src/contracts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
477 | 477 | | |
478 | 478 | | |
479 | 479 | | |
480 | | - | |
481 | | - | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
482 | 493 | | |
483 | | - | |
484 | | - | |
485 | | - | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
486 | 498 | | |
487 | 499 | | |
488 | 500 | | |
| |||
Lines changed: 8 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
143 | 143 | | |
144 | 144 | | |
145 | 145 | | |
146 | | - | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
147 | 152 | | |
148 | 153 | | |
149 | 154 | | |
| |||
174 | 179 | | |
175 | 180 | | |
176 | 181 | | |
177 | | - | |
| 182 | + | |
178 | 183 | | |
179 | 184 | | |
180 | 185 | | |
| |||
Lines changed: 62 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
160 | 160 | | |
161 | 161 | | |
162 | 162 | | |
| 163 | + | |
| 164 | + | |
163 | 165 | | |
164 | 166 | | |
165 | 167 | | |
166 | 168 | | |
167 | 169 | | |
168 | 170 | | |
169 | 171 | | |
170 | | - | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
171 | 175 | | |
172 | 176 | | |
173 | 177 | | |
| |||
181 | 185 | | |
182 | 186 | | |
183 | 187 | | |
184 | | - | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
185 | 194 | | |
186 | 195 | | |
187 | 196 | | |
| |||
194 | 203 | | |
195 | 204 | | |
196 | 205 | | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
197 | 209 | | |
198 | 210 | | |
199 | 211 | | |
| |||
233 | 245 | | |
234 | 246 | | |
235 | 247 | | |
236 | | - | |
| 248 | + | |
| 249 | + | |
237 | 250 | | |
238 | 251 | | |
239 | 252 | | |
| |||
267 | 280 | | |
268 | 281 | | |
269 | 282 | | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
0 commit comments