Skip to content

Commit 7a92586

Browse files
committed
docs(spec,adr-0111,docs): state who may mint in the IShareLinkService.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>
1 parent f0c82ea commit 7a92586

5 files changed

Lines changed: 37 additions & 13 deletions

File tree

‎.changeset/21329-share-link-owner-mint.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
'@objectstack/plugin-sharing': minor
3+
'@objectstack/spec': patch
34
---
45

56
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)
@@ -9,4 +10,6 @@ Clause-②: yes (widening)
910
- **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.
1011
- **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.
1112
- **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.
12-
- **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.
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. `SharingSecurityProbe` gains an optional `explain`, the slice 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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,9 @@ whose write depth covers the owner is **not** admitted without visibility: a
487487
link creates access, so they need to see the record to mint one. Under the
488488
`group` and `isolated` tenancy postures the owner and Modify-All alternatives
489489
are withheld and visibility alone admits, so no mint crosses the organization
490-
wall. The opt-in is checked first and `eligibility` (below) last.
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.
491493
Revoking a link is allowed for the link's **creator**, a **record share-manager**
492494
(the record's owner, a `modifyAllRecords` admin, or a hierarchy manager whose
493495
write depth covers the owner — the same authority as `canManageShares`), or

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

Lines changed: 5 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 — 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 — verified in `share-link-service.test.ts`, at the dispatcher door, 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.
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`)
@@ -145,7 +145,9 @@ Two policy rulings on the already-hardened link surface:
145145

146146
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.)
147147

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. Where an organization 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.
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.
149151

150152
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.
151153

@@ -177,7 +179,7 @@ Buffers instead of a valve: (1) every fail-closed denial logs a specific reason
177179
**Negative / costs**
178180
- Two breaking changes (D3, D7). Mitigated by narrow blast radius, deny-logging, `explain`, and changelog per D10.
179181
- A new cross-plugin contract method (`ISecurityService.hasWriteBypass`) — small, fails closed, mirrors the existing `getReadFilter` posture.
180-
- 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).
181183

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

‎packages/spec/src/contracts/share-link-service.ts‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,9 +219,20 @@ export interface IShareLinkService {
219219
* Mint a new link. Throws when the object is not opt-in or limits are exceeded.
220220
*
221221
* ENFORCEMENT PATH: implementations re-read the target record under
222-
* `context` ([Finding-2] — you may only link-share a record you can
223-
* yourself see), so `context` must be the caller's complete resolved
224-
* envelope. A trimmed one silently changes the verdict of that read.
222+
* `context`, so `context` must be the caller's complete resolved envelope. A
223+
* trimmed one silently changes the verdict of that read.
224+
*
225+
* WHO MAY MINT (ADR-0111 D8 rule 1): the object's `publicSharing` opt-in,
226+
* checked first; then authority over the record — that read SEES it
227+
* ([Finding-2]), OR the caller is the record's owner, OR holds an explicit
228+
* Modify-All (`modifyAllRecords`) bypass on the object; then
229+
* `publicSharing.eligibility`, checked last. A hierarchy manager who manages
230+
* the record's shares (revoke, grant, list) but cannot see it is NOT
231+
* admitted: a link creates access. The owner and Modify-All alternatives are
232+
* withheld where an organization wall is in force (the `group` / `isolated`
233+
* tenancy postures), and they never apply past a capability the object
234+
* requires (`requiredPermissions`, ADR-0066 D3). A caller refused on those
235+
* grounds is answered with the read's own refusal.
225236
*/
226237
createLink(input: CreateShareLinkInput, context: ExecutionContext): Promise<ShareLink>;
227238

‎packages/spec/src/contracts/sharing-service.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -354,14 +354,20 @@ export interface ISharingService {
354354
/**
355355
* [ADR-0111 D1] May the principal in `context` MANAGE shares (grant / revoke
356356
* / list) on `(object, recordId)`? True for system context, the record's
357-
* owner, and holders of the super-user write bypass (`modifyAllRecords`,
358-
* probed via the late-bound security service). **Fails closed**: no security
359-
* service → owner-only; unknown record / principal-less context → `false`.
357+
* owner, holders of the super-user write bypass (`modifyAllRecords`, probed
358+
* via the late-bound security service), and — the ADR-0111 D1 DEPTH
359+
* extension — a hierarchy manager whose effective WRITE scope (`unit` /
360+
* `unit_and_below` / `own_and_reports`, from
361+
* `ISecurityService.resolveWriteScope`) covers the record's owner through the
362+
* enterprise hierarchy resolver. **Fails closed**: no security service →
363+
* owner-only; no hierarchy resolver → owner + Modify-All; unknown record /
364+
* principal-less context → `false`.
360365
*
361366
* This is the single gate every manual share-management operation consults —
362367
* enforcement lives in the SERVICE, so every caller (REST or otherwise) is
363-
* covered. The DEPTH extension (hierarchy managers) is a named ADR-0111
364-
* direction, not implemented here.
368+
* covered. It is NOT mint authority for share links: ADR-0111 D8 rule 1
369+
* admits only its owner and Modify-All branches beside visibility, never the
370+
* hierarchy branch (see `IShareLinkService.createLink`).
365371
*/
366372
canManageShares(
367373
object: string,

0 commit comments

Comments
 (0)