|
1 | 1 | # ADR-0111: Record-share management authority and the verb boundary — sharing needs "who may manage a share" and "which verbs a level grants" |
2 | 2 |
|
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 — 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. |
4 | 4 | **Deciders**: ObjectStack Protocol Architects |
5 | 5 | **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) |
6 | 6 | **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,10 @@ Three write-time refusals so a persisted share always means something: |
143 | 143 |
|
144 | 144 | Two policy rulings on the already-hardened link surface: |
145 | 145 |
|
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. 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. |
| 149 | + |
147 | 150 | 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. |
148 | 151 |
|
149 | 152 | ### D9 — A dedicated `manage_sharing` capability |
|
0 commit comments