Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/21329-share-link-owner-mint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-sharing': minor
'@objectstack/spec': patch
---

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)

Clause-②: yes (widening)

- **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.
- **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.
- **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.
- **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.
- **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.
- **`@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.
22 changes: 17 additions & 5 deletions content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -477,12 +477,24 @@ publicSharing:
```

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

**When `eligibility` is enforced** (#13608). The optional `eligibility` CEL
predicate is a **standing policy about which records may be reached
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0111: Record-share management authority and the verb boundary — sharing needs "who may manage a share" and "which verbs a level grants"

**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.
**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.
**Deciders**: ObjectStack Protocol Architects
**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)
**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`)
Expand Down Expand Up @@ -143,7 +143,12 @@ Three write-time refusals so a persisted share always means something:

Two policy rulings on the already-hardened link surface:

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

*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:
- **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.
- **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.

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.

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

**Neutral / explicitly out of scope**
- **Time-boxed / recertifiable shares** — owned by ADR-0091; this ADR does not touch the lifecycle axis.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,18 @@ interface MintOptions {
memberOf: string[];
/** The record's owning organization. */
recordOrg?: string;
/** The record's `owner_id`; absent ⇒ the row carries none. */
recordOwner?: string;
}

/**
* Boot the real `SharingServicePlugin` and POST `/api/v1/share-links` for
* `crm_account/acc_1` as a signed-in member — the exact call a user makes from
* the record page's "share" button.
*/
async function groupPostureMint(opts: MintOptions): Promise<{ status: number; body: any }> {
async function groupPostureMint(
opts: MintOptions,
): Promise<{ status: number; body: any; links: any[] }> {
const userId = 'u_sharer';
const activeOrg = opts.memberOf[0];
const tables: Record<string, any[]> = {
Expand All @@ -181,7 +185,12 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
sys_user_position: [],
sys_user_permission_set: [],
sys_permission_set: [],
[OBJECT]: [{ id: RECORD, name: 'Acme', organization_id: opts.recordOrg ?? ORG_A }],
[OBJECT]: [{
id: RECORD,
name: 'Acme',
organization_id: opts.recordOrg ?? ORG_A,
...(opts.recordOwner ? { owner_id: opts.recordOwner } : {}),
}],
sys_share_link: [],
};

Expand All @@ -194,6 +203,9 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
getService: (name: string) => {
if (name === 'objectql') return engine;
if (name === 'http-server') return http;
// The posture the sharing service reads (ADR-0105 D1) — the same one the
// engine's Layer 0 applies.
if (name === 'tenancy') return { posture: opts.posture };
if (name === 'auth') {
return {
api: {
Expand Down Expand Up @@ -233,7 +245,8 @@ async function groupPostureMint(opts: MintOptions): Promise<{ status: number; bo
},
res,
);
return captured;
// The store, read back: a refusal is only a refusal if no row landed.
return { ...captured, links: tables.sys_share_link ?? [] };
}

describe('[#6206] share-link creation under the `group` tenancy posture', () => {
Expand Down Expand Up @@ -267,3 +280,49 @@ describe('[#6206] share-link creation under the `group` tenancy posture', () =>
expect(res.status).toBe(201);
});
});

/**
* [ADR-0111 D8 rule 1 — ruling 5950188467, A′] The owner may mint on a record
* their visibility read refuses — but never across the organization wall.
*
* The owner alternative reads `owner_id`, and `owner_id` outlives a
* membership: a member who left the record's organization still owns the rows
* they created there. Under a walled posture the visibility read applies Layer
* 0, and that refusal is the only thing standing between such a member and a
* capability token on their former organization's record, which
* `resolveToken` would then serve anonymously. So where a wall is in force the
* owner and Modify-All alternatives are withheld and visibility alone admits.
*
* Real here, as above: the plugin's own wiring (the link service's
* `canMintWithoutVisibility` probe is the one `SharingServicePlugin` composes)
* and the real `computeTenantLayer0Filter`.
*/
describe('[ADR-0111 D8] the owner alternative does not cross the organization wall', () => {
it.each(['group', 'isolated'] as const)(
'%s: a member of plant B who OWNS a record in plant A is refused, and nothing lands',
async (posture) => {
const res = await groupPostureMint({
posture,
memberOf: [ORG_B],
recordOrg: ORG_A,
recordOwner: 'u_sharer',
});

expect(res.status).toBe(403);
expect(res.body).toMatchObject({ success: false, error: { code: 'FORBIDDEN' } });
expect(res.links).toEqual([]);
},
);

it('control: the same owner, in the record\'s own organization, mints', async () => {
const res = await groupPostureMint({
posture: 'isolated',
memberOf: [ORG_A],
recordOrg: ORG_A,
recordOwner: 'u_sharer',
});
expect(res.status).toBe(201);
expect(res.body.data).toMatchObject({ object_name: OBJECT, record_id: RECORD, created_by: 'u_sharer' });
expect(res.links.map((l) => l.created_by)).toEqual(['u_sharer']);
});
});
Loading
Loading