Skip to content

Commit 4c8363f

Browse files
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

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/spec': patch
4+
---
5+
6+
feat(plugin-sharing): the record owner and an explicit Modify-All holder may mint a share link on a record the data door refuses them (ADR-0111 D8 rule 1, ruling A′) (#21329)
7+
8+
Clause-②: yes (widening)
9+
10+
- **Who may mint.** `ShareLinkService.createLink` admits the caller when they can see the record, **or** own it, **or** hold `modifyAllRecords` on the object. The object's `publicSharing` opt-in is still checked first, and `publicSharing.eligibility` still last. On an object declared `access: { default: 'private' }` no wildcard grant covers the record, so its owner's own read is refused; the owner can now share it anyway. A member who neither sees nor owns the record is refused exactly as before, with the same envelope.
11+
- **Who still needs visibility.** A hierarchy manager whose write depth covers the record's owner manages the record's shares (revoke, grant, list), but is not admitted to mint without seeing the record: a link creates access.
12+
- **The organization wall.** Under the `group` and `isolated` tenancy postures the owner and Modify-All alternatives are withheld and visibility alone admits, as before this release. A member who left an organization still owns the records they created there, and must not be able to publish them by link.
13+
- **A required capability.** Neither alternative applies past a capability the object requires (`requiredPermissions`). An owner or Modify-All holder who lacks it is refused with the capability gate's own refusal, as before this release; an owner who holds it, refused only because no permission set grants the object, mints. The verdict is read from the `required_permissions` layer of `ISecurityService.explain`, so a security service the sharing service reaches must implement `explain`. If it does not, the two alternatives are withheld.
14+
- **API.** `SharingService.canMintWithoutVisibility(object, recordId, context)` answers the two alternatives with the owner and Modify-All branches `canManageShares` reads. `ShareLinkServiceOptions.canMintWithoutVisibility` is the late-bound probe `createLink` asks once the visibility read refuses, and `SharingServicePlugin` wires it. A host that constructs `ShareLinkService` itself without it keeps the visibility rule alone. The probe slice `SharingServiceOptions.securityService` returns gains an optional `explain`, the part of `ISecurityService.explain` the capability verdict reads.
15+
- **`@objectstack/spec` (documentation only).** The `IShareLinkService.createLink` TSDoc states who may mint, replacing "you may only link-share a record you can yourself see". The `ISharingService.canManageShares` TSDoc describes the hierarchy-manager branch, which is implemented, and says it is not mint authority. No schema, key, type or export changes.

‎content/docs/protocol/objectql/security.mdx‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,12 +477,24 @@ publicSharing:
477477
```
478478

479479
**Who may mint and revoke** (ADR-0111 D8). Minting a link requires the object's
480-
`publicSharing` opt-in **and** that the caller can see the record — an opted-in
481-
object deliberately delegates re-share power to anyone who can view the row.
480+
`publicSharing` opt-in **and** authority over the record: the caller can see it,
481+
**or** owns it, **or** holds `modifyAllRecords` on the object. An opted-in object
482+
deliberately delegates re-share power to anyone who can view the row; the owner
483+
and the Modify-All holder may mint even where the data door refuses them, as on
484+
an object declared `access: { default: 'private' }`, which no wildcard grant
485+
covers — so a member can share a record of their own there. A hierarchy manager
486+
whose write depth covers the owner is **not** admitted without visibility: a
487+
link creates access, so they need to see the record to mint one. Under the
488+
`group` and `isolated` tenancy postures the owner and Modify-All alternatives
489+
are withheld and visibility alone admits, so no mint crosses the organization
490+
wall. Nor do they apply past a capability the object requires
491+
(`requiredPermissions`): a caller who lacks it is refused even on a record they
492+
own. The opt-in is checked first and `eligibility` (below) last.
482493
Revoking a link is allowed for the link's **creator**, a **record share-manager**
483-
(the record's owner or a `modifyAllRecords` admin — the same authority as
484-
`canManageShares`), or system context: a link someone else minted on your record
485-
is your record's exposure to kill, not only its creator's.
494+
(the record's owner, a `modifyAllRecords` admin, or a hierarchy manager whose
495+
write depth covers the owner — the same authority as `canManageShares`), or
496+
system context: a link someone else minted on your record is your record's
497+
exposure to kill, not only its creator's.
486498

487499
**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
488500
predicate is a **standing policy about which records may be reached

‎docs/adr/0111-record-share-management-authority-and-verb-boundary.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0111: Record-share management authority and the verb boundary — sharing needs "who may manage a share" and "which verbs a level grants"
22

3-
**Status**: Accepted (2026-07-30) — **P0 + P1 + D8 (framework) implemented**. P0 (D1/D2/D4/D5/D6/D7/D9): `canManageShares` + `hasWriteBypass`, verified by the #3902 Mallory reproduction. P1 (D3, the verb boundary): `canDelete` + verb-split `buildWriteFilter` in `plugin-sharing/src/sharing-service.ts`, routed by the middleware and `/security/explain`, verified by the "edit share cannot delete" suite. D8 (share-link re-share): `ShareLinkService.revokeLink` now admits a record share-manager via the late-bound `canManageShares` probe, and the mint-authority ruling (publicSharing opt-in + visibility) is enforced as before — verified in `share-link-service.test.ts`. D1 DEPTH extension: `canManageShares` now admits a hierarchy manager whose effective write scope covers the record's owner, via the new `ISecurityService.resolveWriteScope` probe + the enterprise `hierarchy-scope-resolver` (framework side, fails closed to owner+Modify-All without the resolver). **Still open**: the cloud-side wiring for both D8 and the DEPTH extension (`HttpDispatcher.handleShareLinks` + a `.objectstack-sha` bump + `security-enterprise` integration tests, in `objectstack-ai/cloud`) — batched into one framework-SHA bump per this ADR's rollout.
3+
**Status**: Accepted (2026-07-30) — **P0 + P1 + D8 (framework) implemented**. P0 (D1/D2/D4/D5/D6/D7/D9): `canManageShares` + `hasWriteBypass`, verified by the #3902 Mallory reproduction. P1 (D3, the verb boundary): `canDelete` + verb-split `buildWriteFilter` in `plugin-sharing/src/sharing-service.ts`, routed by the middleware and `/security/explain`, verified by the "edit share cannot delete" suite. D8 (share-link re-share): `ShareLinkService.revokeLink` now admits a record share-manager via the late-bound `canManageShares` probe — verified in `share-link-service.test.ts`. **D8 rule 1 amended 2026-10-02** by maintainer ruling `5950188467` on objectstack-ai/objectstack#21329 (option A′): mint authority is the `publicSharing` opt-in AND (visibility, or the record owner, or an explicit Modify-All bypass), implemented as `ShareLinkService.createLink` asking `SharingService.canMintWithoutVisibility` (`canManageShares`' owner and bypass branches, without its DEPTH branch) once the visibility read refuses, with the two alternatives withheld under an organization wall and past a missing required capability — verified in `share-link-service.test.ts`, at both share-link doors, and at the organization wall. D1 DEPTH extension: `canManageShares` now admits a hierarchy manager whose effective write scope covers the record's owner, via the new `ISecurityService.resolveWriteScope` probe + the enterprise `hierarchy-scope-resolver` (framework side, fails closed to owner+Modify-All without the resolver). **Still open**: the cloud-side wiring for both D8 and the DEPTH extension (`HttpDispatcher.handleShareLinks` + a `.objectstack-sha` bump + `security-enterprise` integration tests, in `objectstack-ai/cloud`) — batched into one framework-SHA bump per this ADR's rollout.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a security property that parses but enforces nothing is worse than absent), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (DEPTH scopes + the `sys_record_share` / `sys_sharing_rule` split), [ADR-0066](./0066-unified-authorization-model.md) (unified capability model; `modifyAllRecords` super-user bit), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata — a persisted share level or recipient type that no gate consults is exactly this), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D1 secure-default OWD, D4 retired aliases, D10 delegated identity intersection), [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (time-boxed grants — the lifecycle axis this ADR deliberately does not re-open)
66
**Consumers**: `@objectstack/plugin-sharing` (`sharing-service.ts`, `sharing-rule-service.ts`, `share-link-service.ts`, `sharing-plugin.ts`), `@objectstack/plugin-security` (`ISecurityService` — a write-bypass probe), `@objectstack/rest` (`rest-server.ts` sharing / sharing-rule / share-link routes), `@objectstack/spec` (`contracts/sharing-service.ts`, `security/capabilities.ts`)
@@ -143,7 +143,12 @@ Three write-time refusals so a persisted share always means something:
143143

144144
Two policy rulings on the already-hardened link surface:
145145

146-
1. **Mint authority = record visibility AND the object's `publicSharing` opt-in.** A `publicSharing`-enabled object deliberately delegates re-share power to anyone who can see the record; this is now a **stated** decision rather than an emergent one. Objects that do not opt in cannot be link-shared at all. (A future tightening to require share-management authority for minting is recorded as D-future, not taken now — it would break existing `publicSharing` flows.)
146+
1. **Mint authority = the object's `publicSharing` opt-in AND (record visibility, or the record owner, or an explicit Modify-All bypass).** A `publicSharing`-enabled object deliberately delegates re-share power to anyone who can see the record; this is now a **stated** decision rather than an emergent one. Objects that do not opt in cannot be link-shared at all, by their owner included. (A tightening to require share-management authority for every mint stays recorded as D-future and untaken — it would break existing `publicSharing` flows. The amendment below moves the other way, and for two principals only: it admits authority *beside* visibility, never in place of it.)
147+
148+
*Amended by maintainer ruling `5950188467` (objectstack-ai/objectstack#21329, option A′).* On an owner-private object (`access.default: 'private'`) the data door refuses the record's owner too, so on visibility alone an owner could never share their own record. Two principals may therefore mint where the visibility read refuses them: the **record owner**, the one principal whose own record is the thing shared, and an **explicit `modifyAllRecords` holder**, who already reads everything. These are `canManageShares`' own owner and bypass branches (D1), read once — no second notion of ownership. A **hierarchy manager still needs visibility** to mint: their D1 DEPTH authority covers revoke, grant and list (rule 2, D4, D5), but a link *creates* access, and write depth can cover a record the data door will not show them. The opt-in is checked first and `publicSharing.eligibility` last, against the row the anonymous holder would be served. Two withholdings narrow the ruled letter. Each is a named part of this amendment's approval, and where one applies visibility alone admits, as before the amendment:
149+
- **The organization wall.** Where a wall is in force (`group` / `isolated`, ADR-0105 D1) both alternatives are withheld and visibility alone admits: the visibility read is what applies Layer 0, the owner column outlives a membership, and the Modify-All probe is object-wide, so neither alternative may carry a mint across the wall.
150+
- **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 and neither alternative applies past it. The owner exception is about the record row — the owner's own record is the thing shared — not about a capability an administrator withheld from the caller for the whole object, and a Modify-All holder without it does not "already read everything". The gate's verdict is the declared `required_permissions` layer of `ISecurityService.explain`, which the explain engine computes with the read gate's own capability fold, so it tells this refusal apart from the owner-private CRUD refusal without a new contract method.
151+
147152
2. **A record's share-manager may revoke any link on that record.** Today `revokeLink` is creator-or-system only, so a record owner / `modifyAllRecords` admin cannot kill a link someone else minted on their record. Revoke authority becomes: creator **or** `canManageShares(link.object, link.recordId, context)` **or** system.
148153

149154
### D9 — A dedicated `manage_sharing` capability
@@ -174,7 +179,7 @@ Buffers instead of a valve: (1) every fail-closed denial logs a specific reason
174179
**Negative / costs**
175180
- Two breaking changes (D3, D7). Mitigated by narrow blast radius, deny-logging, `explain`, and changelog per D10.
176181
- A new cross-plugin contract method (`ISecurityService.hasWriteBypass`) — small, fails closed, mirrors the existing `getReadFilter` posture.
177-
- The MVP does not give hierarchy managers share-management authority (D1 DEPTH is deferred); until the extension lands, a manager sharing a subordinate's record is done by an admin or the owner.
182+
- Hierarchy managers' share-management authority (D1 DEPTH) landed after the MVP and depends on the enterprise hierarchy resolver: without it the gate fails closed to owner + Modify-All. Even with it, that authority covers grant, revoke and list, never minting a share link on a record the manager cannot see (D8 rule 1).
178183

179184
**Neutral / explicitly out of scope**
180185
- **Time-boxed / recertifiable shares** — owned by ADR-0091; this ADR does not touch the lifecycle axis.

‎packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts‎

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,14 +160,18 @@ interface MintOptions {
160160
memberOf: string[];
161161
/** The record's owning organization. */
162162
recordOrg?: string;
163+
/** The record's `owner_id`; absent ⇒ the row carries none. */
164+
recordOwner?: string;
163165
}
164166

165167
/**
166168
* Boot the real `SharingServicePlugin` and POST `/api/v1/share-links` for
167169
* `crm_account/acc_1` as a signed-in member — the exact call a user makes from
168170
* the record page's "share" button.
169171
*/
170-
async function groupPostureMint(opts: MintOptions): Promise<{ status: number; body: any }> {
172+
async function groupPostureMint(
173+
opts: MintOptions,
174+
): Promise<{ status: number; body: any; links: any[] }> {
171175
const userId = 'u_sharer';
172176
const activeOrg = opts.memberOf[0];
173177
const tables: Record<string, any[]> = {
@@ -181,7 +185,12 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
181185
sys_user_position: [],
182186
sys_user_permission_set: [],
183187
sys_permission_set: [],
184-
[OBJECT]: [{ id: RECORD, name: 'Acme', organization_id: opts.recordOrg ?? ORG_A }],
188+
[OBJECT]: [{
189+
id: RECORD,
190+
name: 'Acme',
191+
organization_id: opts.recordOrg ?? ORG_A,
192+
...(opts.recordOwner ? { owner_id: opts.recordOwner } : {}),
193+
}],
185194
sys_share_link: [],
186195
};
187196

@@ -194,6 +203,9 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
194203
getService: (name: string) => {
195204
if (name === 'objectql') return engine;
196205
if (name === 'http-server') return http;
206+
// The posture the sharing service reads (ADR-0105 D1) — the same one the
207+
// engine's Layer 0 applies.
208+
if (name === 'tenancy') return { posture: opts.posture };
197209
if (name === 'auth') {
198210
return {
199211
api: {
@@ -233,7 +245,8 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
233245
},
234246
res,
235247
);
236-
return captured;
248+
// The store, read back: a refusal is only a refusal if no row landed.
249+
return { ...captured, links: tables.sys_share_link ?? [] };
237250
}
238251

239252
describe('[#6206] share-link creation under the `group` tenancy posture', () => {
@@ -267,3 +280,49 @@ describe('[#6206] share-link creation under the `group` tenancy posture', () =>
267280
expect(res.status).toBe(201);
268281
});
269282
});
283+
284+
/**
285+
* [ADR-0111 D8 rule 1 — ruling 5950188467, A′] The owner may mint on a record
286+
* their visibility read refuses — but never across the organization wall.
287+
*
288+
* The owner alternative reads `owner_id`, and `owner_id` outlives a
289+
* membership: a member who left the record's organization still owns the rows
290+
* they created there. Under a walled posture the visibility read applies Layer
291+
* 0, and that refusal is the only thing standing between such a member and a
292+
* capability token on their former organization's record, which
293+
* `resolveToken` would then serve anonymously. So where a wall is in force the
294+
* owner and Modify-All alternatives are withheld and visibility alone admits.
295+
*
296+
* Real here, as above: the plugin's own wiring (the link service's
297+
* `canMintWithoutVisibility` probe is the one `SharingServicePlugin` composes)
298+
* and the real `computeTenantLayer0Filter`.
299+
*/
300+
describe('[ADR-0111 D8] the owner alternative does not cross the organization wall', () => {
301+
it.each(['group', 'isolated'] as const)(
302+
'%s: a member of plant B who OWNS a record in plant A is refused, and nothing lands',
303+
async (posture) => {
304+
const res = await groupPostureMint({
305+
posture,
306+
memberOf: [ORG_B],
307+
recordOrg: ORG_A,
308+
recordOwner: 'u_sharer',
309+
});
310+
311+
expect(res.status).toBe(403);
312+
expect(res.body).toMatchObject({ success: false, error: { code: 'FORBIDDEN' } });
313+
expect(res.links).toEqual([]);
314+
},
315+
);
316+
317+
it('control: the same owner, in the record\'s own organization, mints', async () => {
318+
const res = await groupPostureMint({
319+
posture: 'isolated',
320+
memberOf: [ORG_A],
321+
recordOrg: ORG_A,
322+
recordOwner: 'u_sharer',
323+
});
324+
expect(res.status).toBe(201);
325+
expect(res.body.data).toMatchObject({ object_name: OBJECT, record_id: RECORD, created_by: 'u_sharer' });
326+
expect(res.links.map((l) => l.created_by)).toEqual(['u_sharer']);
327+
});
328+
});

0 commit comments

Comments
 (0)