diff --git a/.changeset/21329-share-link-owner-mint.md b/.changeset/21329-share-link-owner-mint.md new file mode 100644 index 00000000000..85e38a7a52d --- /dev/null +++ b/.changeset/21329-share-link-owner-mint.md @@ -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. diff --git a/content/docs/protocol/objectql/security.mdx b/content/docs/protocol/objectql/security.mdx index aa697290388..305cd343315 100644 --- a/content/docs/protocol/objectql/security.mdx +++ b/content/docs/protocol/objectql/security.mdx @@ -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 diff --git a/docs/adr/0111-record-share-management-authority-and-verb-boundary.md b/docs/adr/0111-record-share-management-authority-and-verb-boundary.md index 1540e354982..7e8be31dbfb 100644 --- a/docs/adr/0111-record-share-management-authority-and-verb-boundary.md +++ b/docs/adr/0111-record-share-management-authority-and-verb-boundary.md @@ -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`) @@ -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 @@ -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. diff --git a/packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts b/packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts index bfbb26e3561..ffd1dbcf9fb 100644 --- a/packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts +++ b/packages/plugins/plugin-security/src/share-link-tenant-wall.test.ts @@ -160,6 +160,8 @@ interface MintOptions { memberOf: string[]; /** The record's owning organization. */ recordOrg?: string; + /** The record's `owner_id`; absent ⇒ the row carries none. */ + recordOwner?: string; } /** @@ -167,7 +169,9 @@ interface MintOptions { * `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 = { @@ -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: [], }; @@ -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: { @@ -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', () => { @@ -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']); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-service.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.test.ts index e4a1822e3ac..c11f5de49c5 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { ShareLinkService } from './share-link-service.js'; +import { SharingService } from './sharing-service.js'; interface FakeRow { [k: string]: any } @@ -1170,3 +1171,339 @@ describe('[#12981] a refused usage stamp is reported ONCE as a durability degrad await expect(service.resolveToken(link.token)).resolves.not.toBeNull(); }); }); + +// ── [ADR-0111 D8 rule 1 — ruling 5950188467, A′] who may mint ──────────────── +// +// "`createLink` admits `visibility OR owner OR Modify-All bypass`, still behind +// the `publicSharing` opt-in and before its `eligibility` check. … A hierarchy +// manager still needs visibility to mint." The owner and bypass halves are +// `canManageShares`' own first two branches, so the link service here is wired +// to a REAL `SharingService` exactly as `SharingServicePlugin` wires it; only +// its probes (the security service, the enterprise hierarchy resolver, the +// tenancy posture) are doubles. +// +// The object is OWNER-PRIVATE: the double below refuses every non-system read +// of it the way the CRUD gate does on an `access.default: 'private'` object +// (`PERMISSION_DENIED` with `statusCode` 403), except for `u_reader`, the one +// principal who can see it. So on the visibility rule alone nobody else — +// the owner included — could ever mint. +describe('[ADR-0111 D8 rule 1 / ruling A′] mint authority: visibility, or the record owner, or Modify-All', () => { + const OWNER = 'u_owner'; + const STRANGER = 'u_stranger'; + const ADMIN = 'u_admin'; + const MANAGER = 'u_manager'; + const READER = 'u_reader'; + /** Owns a record on the capability-gated object AND holds the capability. */ + const CAPABLE_OWNER = 'u_capable_owner'; + + const SCHEMAS = { + sys_share_link: { name: 'sys_share_link', fields: {} }, + conversations: { + name: 'conversations', + access: { default: 'private' }, + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + fields: { id: {}, title: {}, owner_id: {} }, + }, + // The same object shape with an eligibility predicate, for the ordering pins. + briefs: { + name: 'briefs', + access: { default: 'private' }, + publicSharing: { enabled: true, eligibility: "record.status == 'published'" }, + fields: { id: {}, title: {}, status: {}, owner_id: {} }, + }, + // Never opted in. + notes: { name: 'notes', access: { default: 'private' }, fields: { id: {}, owner_id: {} } }, + // Owner-private AND capability-gated (ADR-0066 D3): a read needs `view_vault`. + vault: { + name: 'vault', + access: { default: 'private' }, + requiredPermissions: ['view_vault'], + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + fields: { id: {}, owner_id: {} }, + }, + }; + const PRIVATE_OBJECTS = new Set(['conversations', 'briefs', 'notes', 'vault']); + /** The capabilities each principal holds; the doubles below read only this. */ + const HELD_CAPABILITIES: Record = { [CAPABLE_OWNER]: ['view_vault'] }; + const requiredOf = (object: string): string[] => + ((SCHEMAS as Record)[object]?.requiredPermissions as string[] | undefined) ?? []; + const lacksCapability = (object: string, userId: unknown): boolean => + requiredOf(object).some((c) => !(HELD_CAPABILITIES[String(userId)] ?? []).includes(c)); + + const denial = () => + Object.assign(new Error('You do not have permission to perform this action.'), { + code: 'PERMISSION_DENIED', + statusCode: 403, + }); + /** The capability AND-gate's refusal: the same class, carrying what was missing. */ + const capabilityDenial = (object: string) => + Object.assign(new Error('You do not have permission to perform this action.'), { + code: 'PERMISSION_DENIED', + statusCode: 403, + details: { object, requiredPermissions: requiredOf(object), missingPermissions: requiredOf(object) }, + }); + /** + * The explain report's `required_permissions` layer, computed from the same + * `HELD_CAPABILITIES` the read double refuses with — so the two agree by + * construction, as the real engine and middleware do. + */ + const explainDouble = async (request: { object: string }, ctx: any) => { + explainCalls += 1; + const verdict = requiredOf(request.object).length === 0 + ? 'not_applicable' + : lacksCapability(request.object, ctx?.userId) ? 'denies' : 'neutral'; + return { layers: [{ layer: 'required_permissions', verdict, detail: 'double' }] } as any; + }; + + let engine: ReturnType; + let posture: string | undefined; + let writeScopeCalls: number; + let explainCalls: number; + let sharing: SharingService; + let service: ShareLinkService; + let mintProbeCalls: Array<[string, string, string | undefined]>; + + const mintIn = (object: string, recordId: string) => + ({ object, recordId, audience: 'link_only', permission: 'view' }) as const; + const conversation = () => mintIn('conversations', 'c1'); + const as = (userId: string) => ({ userId }) as any; + const minted = () => (engine._tables.sys_share_link ?? []).map((r) => r.created_by); + + beforeEach(() => { + engine = makeFakeEngine(SCHEMAS); + engine._tables.conversations = [{ id: 'c1', title: 'My chat', owner_id: OWNER }]; + engine._tables.briefs = [ + { id: 'b_draft', title: 'Draft', status: 'draft', owner_id: OWNER }, + { id: 'b_pub', title: 'Published', status: 'published', owner_id: OWNER }, + ]; + engine._tables.notes = [{ id: 'n1', owner_id: OWNER }]; + engine._tables.vault = [ + { id: 'v_owner', owner_id: OWNER }, + { id: 'v_capable', owner_id: CAPABLE_OWNER }, + ]; + const plainFind = engine.find.bind(engine); + engine.find = async (object: string, options?: any) => { + const ctx = options?.context ?? {}; + if (PRIVATE_OBJECTS.has(object) && ctx.isSystem !== true) { + // The capability AND-gate runs BEFORE the CRUD grant (ADR-0066 D3). + if (lacksCapability(object, ctx.userId)) throw capabilityDenial(object); + if (ctx.userId !== READER) throw denial(); + } + return plainFind(object, options); + }; + + posture = 'single'; + writeScopeCalls = 0; + explainCalls = 0; + mintProbeCalls = []; + sharing = new SharingService({ + engine: engine as any, + securityService: () => ({ + hasWriteBypass: async (_object: string, ctx: any) => ctx?.userId === ADMIN, + resolveWriteScope: async (_object: string, ctx: any) => { + writeScopeCalls += 1; + return ctx?.userId === MANAGER ? 'unit' : 'own'; + }, + explain: explainDouble, + }), + // The enterprise seam: the manager's `unit` covers the owner. + hierarchyResolver: () => + ({ + resolveOwnerIds: async (ctx: any, scope: string) => + ctx?.userId === MANAGER && scope === 'unit' ? [MANAGER, OWNER] : [String(ctx?.userId)], + }) as any, + tenancy: () => (posture === undefined ? null : { posture }), + }); + // Wired the way `SharingServicePlugin` wires it. + service = new ShareLinkService({ + engine: engine as any, + canManageShares: (o, r, c) => sharing.canManageShares(o, r, c), + canMintWithoutVisibility: (o, r, c) => { + mintProbeCalls.push([o, r, (c as any)?.userId]); + return sharing.canMintWithoutVisibility(o, r, c); + }, + }); + }); + + it('[persona] nobody but the reader sees the record: the owner\'s own read is refused', async () => { + await expect(engine.find('conversations', { where: { id: 'c1' }, context: as(OWNER) })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(await engine.find('conversations', { where: { id: 'c1' }, context: as(READER) })).toHaveLength(1); + }); + + it('the owner mints on an owner-private object, and the link resolves', async () => { + const link = await service.createLink(conversation(), as(OWNER)); + expect(link).toMatchObject({ object_name: 'conversations', record_id: 'c1', created_by: OWNER }); + expect(minted()).toEqual([OWNER]); + await expect(service.resolveToken(link.token)).resolves.toMatchObject({ link: { id: link.id } }); + }); + + it('a hierarchy manager without visibility is refused, though they ARE a share-manager of the record', async () => { + // The control: ADR-0111 D1 DEPTH admits this principal to manage shares. + expect(await sharing.canManageShares('conversations', 'c1', as(MANAGER))).toBe(true); + const callsBefore = writeScopeCalls; + + await expect(service.createLink(conversation(), as(MANAGER))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(minted()).toEqual([]); + // Minting never asked for the caller's write scope: the DEPTH branch is not + // part of mint authority, rather than a branch that happened to say no. + expect(writeScopeCalls, 'the mint consulted the hierarchy-depth probe').toBe(callsBefore); + }); + + it('a non-owner member is refused with the visibility read\'s own refusal', async () => { + await expect(service.createLink(conversation(), as(STRANGER))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(minted()).toEqual([]); + }); + + it('Modify-All mints, with the visibility read refused (the bypass branch alone admits)', async () => { + await expect(engine.find('conversations', { where: { id: 'c1' }, context: as(ADMIN) })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + const link = await service.createLink(conversation(), as(ADMIN)); + expect(link).toMatchObject({ record_id: 'c1', created_by: ADMIN }); + }); + + it('a system caller keeps its 404 for a missing record, with the probe wired', async () => { + await expect(service.createLink(mintIn('conversations', 'ghost'), { isSystem: true, userId: OWNER } as any)) + .rejects.toMatchObject({ status: 404, code: 'RECORD_NOT_FOUND' }); + expect(minted()).toEqual([]); + }); + + it('visibility still admits on its own, without asking the owner/bypass probe', async () => { + const link = await service.createLink(conversation(), as(READER)); + expect(link.created_by).toBe(READER); + expect(mintProbeCalls).toEqual([]); + }); + + it('an empty visibility read stays 403 FORBIDDEN for a caller no alternative admits', async () => { + // A row filter, not a CRUD refusal: the read answers nothing. + engine.find = (async (object: string, options?: any) => + options?.context?.isSystem === true ? engine._tables[object] ?? [] : []) as any; + await expect(service.createLink(conversation(), as(STRANGER))) + .rejects.toMatchObject({ status: 403, code: 'FORBIDDEN' }); + // ...and the owner of the same row mints past it. + await expect(service.createLink(conversation(), as(OWNER))).resolves.toMatchObject({ created_by: OWNER }); + }); + + describe('the capability hard stop (ADR-0066 D3): no alternative applies past a missing required capability', () => { + it('an owner lacking the capability is refused with the capability refusal itself, and nothing lands', async () => { + const refusal = await service.createLink(mintIn('vault', 'v_owner'), as(OWNER)).then( + () => { throw new Error('the owner minted past the capability gate'); }, + (err) => err, + ); + expect(refusal).toMatchObject({ + code: 'PERMISSION_DENIED', + statusCode: 403, + details: { missingPermissions: ['view_vault'] }, + }); + expect(minted()).toEqual([]); + // The owner alternative itself was admitted; the stop is what refused. + expect(await sharing.canManageShares('vault', 'v_owner', as(OWNER))).toBe(true); + }); + + it('a Modify-All holder lacking the capability is refused the same way', async () => { + await expect(service.createLink(mintIn('vault', 'v_owner'), as(ADMIN))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', details: { missingPermissions: ['view_vault'] } }); + expect(minted()).toEqual([]); + }); + + it('control: an owner who HOLDS the capability mints on the same object (refused only by the CRUD grant)', async () => { + await expect(engine.find('vault', { where: { id: 'v_capable' }, context: as(CAPABLE_OWNER) })) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + await expect(service.createLink(mintIn('vault', 'v_capable'), as(CAPABLE_OWNER))) + .resolves.toMatchObject({ record_id: 'v_capable', created_by: CAPABLE_OWNER }); + }); + + it('control: the owner of an object that requires no capability still mints', async () => { + await expect(service.createLink(conversation(), as(OWNER))).resolves.toMatchObject({ created_by: OWNER }); + }); + + it('a refused stranger never pays for the explain walk', async () => { + await expect(service.createLink(mintIn('vault', 'v_owner'), as(STRANGER))).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + await expect(service.createLink(conversation(), as(STRANGER))).rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + expect(explainCalls).toBe(0); + }); + + it.each([ + ['a security service without explain', { hasWriteBypass: async () => false }], + ['an explain that throws', { explain: async () => { throw new Error('explain down'); } }], + ['a report without the layer', { explain: async () => ({ layers: [] }) }], + ['a verdict outside admits', { explain: async () => ({ layers: [{ layer: 'required_permissions', verdict: 'narrows', detail: 'x' }] }) }], + ])('fails closed: %s refuses the owner', async (_name, probe) => { + const closed = new SharingService({ + engine: engine as any, + securityService: () => probe as any, + tenancy: () => ({ posture: 'single' }), + }); + expect(await closed.canMintWithoutVisibility('conversations', 'c1', as(OWNER))).toBe(false); + }); + + it('a deployment with no security service at all enforces no capability gate, so the owner alternative stands', async () => { + const open = new SharingService({ engine: engine as any, tenancy: () => ({ posture: 'single' }) }); + expect(await open.canMintWithoutVisibility('conversations', 'c1', as(OWNER))).toBe(true); + }); + }); + + describe('the order: opt-in, then authority, then eligibility', () => { + it('the opt-in comes first — the owner of a record on an object that never opted in is refused 422', async () => { + await expect(service.createLink(mintIn('notes', 'n1'), as(OWNER))) + .rejects.toMatchObject({ status: 422, code: 'SHARING_NOT_ENABLED' }); + expect(mintProbeCalls).toEqual([]); + }); + + it('eligibility is judged after the owner is admitted: an ineligible record is refused 422, an eligible one mints', async () => { + await expect(service.createLink(mintIn('briefs', 'b_draft'), as(OWNER))) + .rejects.toMatchObject({ status: 422, code: 'RECORD_NOT_ELIGIBLE' }); + await expect(service.createLink(mintIn('briefs', 'b_pub'), as(OWNER))) + .resolves.toMatchObject({ record_id: 'b_pub', created_by: OWNER }); + }); + + it('a caller with no authority is refused BEFORE eligibility, learning nothing about the record', async () => { + // The draft would draw RECORD_NOT_ELIGIBLE; the stranger never reaches it. + await expect(service.createLink(mintIn('briefs', 'b_draft'), as(STRANGER))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + }); + }); + + describe('where the alternatives are withheld', () => { + it.each(['isolated', 'group'])( + 'an organization wall (%s): visibility alone admits, so the owner and Modify-All are refused', + async (walled) => { + posture = walled; + for (const who of [OWNER, ADMIN]) { + await expect(service.createLink(conversation(), as(who))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + } + expect(minted()).toEqual([]); + // Revoke authority is untouched by the wall: the owner still manages the record. + expect(await sharing.canManageShares('conversations', 'c1', as(OWNER))).toBe(true); + }, + ); + + it('an unresolvable posture counts as walled (fail closed)', async () => { + posture = undefined; + await expect(service.createLink(conversation(), as(OWNER))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }); + + it('a deployment without the probe keeps visibility alone (the pre-A′ rule)', async () => { + const probeless = new ShareLinkService({ + engine: engine as any, + canManageShares: (o, r, c) => sharing.canManageShares(o, r, c), + }); + await expect(probeless.createLink(conversation(), as(OWNER))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED' }); + }); + + it('a throwing probe is a refusal, answered with the visibility read\'s own envelope', async () => { + const broken = new ShareLinkService({ + engine: engine as any, + canMintWithoutVisibility: async () => { throw new Error('probe down'); }, + }); + await expect(broken.createLink(conversation(), as(OWNER))) + .rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(minted()).toEqual([]); + }); + }); +}); diff --git a/packages/plugins/plugin-sharing/src/share-link-service.ts b/packages/plugins/plugin-sharing/src/share-link-service.ts index 368144fb9b7..88b4b0e1895 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.ts @@ -431,6 +431,27 @@ export interface ShareLinkServiceOptions { recordId: string, context: ExecutionContext, ) => Promise; + /** + * [ADR-0111 D8 rule 1 — ruling 5950188467, A′] Late-bound mint-authority + * probe (the sharing service's `canMintWithoutVisibility`): may the caller + * mint on a record their visibility read refused, because they are its + * OWNER or hold the explicit Modify-All bypass? `createLink` asks it only + * after that read refused. It answers with `canManageShares`' own owner and + * bypass branches and never with its hierarchy-depth branch. It is withheld + * where an organization wall is in force, and it answers `false` when the + * refusal was the object's capability AND-gate (`requiredPermissions`, + * ADR-0066 D3), which is a hard stop the alternatives do not pass. + * + * Absent → visibility alone admits (the pre-A′ rule), so a deployment + * without the sharing service never widens who may mint. A throwing probe + * is a refusal. It receives the caller's COMPLETE envelope, like every + * enforcement probe here. + */ + canMintWithoutVisibility?: ( + object: string, + recordId: string, + context: ExecutionContext, + ) => Promise; /** [#5190] Optional logger for the record-delete cascade / orphan sweep. */ logger?: { info?: (msg: any, ...rest: any[]) => void; @@ -474,6 +495,7 @@ export class ShareLinkService implements IShareLinkService { recordId: string, context: ExecutionContext, ) => Promise; + private readonly canMintWithoutVisibility?: ShareLinkServiceOptions['canMintWithoutVisibility']; private readonly logger?: ShareLinkServiceOptions['logger']; /** * [#12981] Latched by the FIRST refused usage stamp on this instance and @@ -488,6 +510,7 @@ export class ShareLinkService implements IShareLinkService { this.hashPassword = opts.hashPassword ?? defaultHashPassword; this.verifyPassword = opts.verifyPassword ?? defaultVerifyPassword; this.canManageShares = opts.canManageShares; + this.canMintWithoutVisibility = opts.canMintWithoutVisibility; this.logger = opts.logger; } @@ -501,11 +524,13 @@ export class ShareLinkService implements IShareLinkService { const schema = this.engine.getSchema?.(input.object); const policy = getPolicy(schema); - // [ADR-0111 D8] Mint authority = the object's `publicSharing` opt-in (this - // check) AND the caller's visibility of the record (the RLS-scoped read - // below). An object that opts into publicSharing deliberately delegates - // re-share power to anyone who can SEE the record — a stated decision, not - // an accident. Objects that do not opt in cannot be link-shared at all. + // [ADR-0111 D8 rule 1] Mint authority = the object's `publicSharing` opt-in + // (this check, FIRST) AND — after the request-shape checks below — the + // caller's authority over the record: visibility (the RLS-scoped read), or + // the record owner, or an explicit Modify-All bypass (ruling 5950188467, + // A′). An object that opts into publicSharing deliberately delegates + // re-share power to anyone who can SEE the record. Objects that do not opt + // in cannot be link-shared at all, by their owner included. if (!policy.enabled && !this.permissive && !context.isSystem) { throw makeError( 422, @@ -537,10 +562,11 @@ export class ShareLinkService implements IShareLinkService { } // Confirm the target record exists AND — for an HTTP caller — that the - // caller may actually SEE it. [Finding-2] Reading under the caller's own - // context (positions/permissions/RLS) means you can only mint a link for a - // record you can access; a client can no longer share arbitrary rows of a - // publicSharing-enabled object it cannot see. Internal (isSystem) callers + // caller holds authority over it. [Finding-2] Reading under the caller's + // own context (positions/permissions/RLS) means a client can no longer + // share arbitrary rows of a publicSharing-enabled object it cannot see; + // the only callers who mint past a refused read are the record's owner and + // an explicit Modify-All holder (A′, below). Internal (isSystem) callers // read under the system context as before. // // [commit 8e13ca876] `context` is passed through UNCHANGED — it is the caller's whole @@ -556,32 +582,67 @@ export class ShareLinkService implements IShareLinkService { // a second query being issued, and it widens ONLY then, so an object // without the key keeps the exact `id`-only read it always had. const eligibility = policy.enabled ? policy.eligibility : undefined; - const exists = await this.engine.find(input.object, { - where: { id: input.recordId }, - ...(eligibility ? {} : { fields: ['id'] }), - limit: 1, - context: context.isSystem ? SYSTEM_CTX : context, - } as any); - if (!Array.isArray(exists) || exists.length === 0) { + const readRecord = async (readContext: ExecutionContext | typeof SYSTEM_CTX) => { + const rows = await this.engine.find(input.object, { + where: { id: input.recordId }, + ...(eligibility ? {} : { fields: ['id'] }), + limit: 1, + context: readContext, + } as any); + return Array.isArray(rows) ? (rows[0] as Record | undefined) : undefined; + }; + + // Visibility first. A refusal the read THROWS (the CRUD gate's, on an + // object the caller holds no read grant on) is kept, not swallowed: it is + // re-thrown below unless an alternative admits, so a caller refused before + // A′ is refused with the same envelope after it. + let record: Record | undefined; + let visibilityRefusal: unknown; + try { + record = await readRecord(context.isSystem ? SYSTEM_CTX : context); + } catch (err) { + visibilityRefusal = err; + } + + // [ruling 5950188467, A′] The owner and Modify-All alternatives, asked only + // once visibility has refused. The probe is the sharing service's + // `canMintWithoutVisibility`: `canManageShares`' owner and bypass branches + // WITHOUT its hierarchy-depth branch — a hierarchy manager still needs + // visibility to mint — withheld under an organization wall, and never past + // a capability the object requires (ADR-0066 D3): when the read was refused + // for a missing `requiredPermissions` capability, the probe answers `false` + // and that refusal is re-thrown as it came. Admitted, the record is read + // under the system context: the caller's authority is established, and the + // eligibility gate below must judge the row the anonymous holder will be + // served (`resolveToken` reads it the same way). + // + // A system caller reaches the probe only when its own system-context read + // found nothing or failed, and the probe grants it nothing it lacks: it + // admits only on a row it re-reads under that same system context. So a + // missing record still answers a system caller 404, and a failing read + // still fails. + if (!record && this.canMintWithoutVisibility) { + const admitted = await this.canMintWithoutVisibility(input.object, input.recordId, context) + .catch(() => false); + if (admitted) record = await readRecord(SYSTEM_CTX); + } + + if (!record) { + if (visibilityRefusal !== undefined) throw visibilityRefusal; // Don't distinguish "missing" from "not visible" for an untrusted caller. throw context.isSystem ? makeError(404, 'RECORD_NOT_FOUND', `${input.object}/${input.recordId} does not exist`) : makeError(403, 'FORBIDDEN', `Not permitted to share ${input.object}/${input.recordId}`); } - // [#7861] The declared eligibility gate. Placed AFTER the visibility read - // (it needs the record, and a caller who cannot see the row must not learn - // anything about its contents from the refusal) and BEFORE the insert, so - // an ineligible link is never MINTED — which is the only placement that - // helps, since `resolveToken` serves an existing row anonymously under - // `SYSTEM_CTX` with no auth check to fall back on. + // [#7861] The declared eligibility gate. Placed AFTER the authority check + // (it needs the record, and a caller with no authority over the row must + // not learn anything about its contents from the refusal) and BEFORE the + // insert, so an ineligible link is never MINTED — which is the only + // placement that helps, since `resolveToken` serves an existing row + // anonymously under `SYSTEM_CTX` with no auth check to fall back on. if (eligibility) { - assertEligible( - eligibility, - (exists[0] ?? {}) as Record, - schema, - input.object, - ); + assertEligible(eligibility, record, schema, input.object); } const maxDays = policy.maxExpiryDays ?? DEFAULT_MAX_EXPIRY_DAYS; diff --git a/packages/plugins/plugin-sharing/src/sharing-plugin.ts b/packages/plugins/plugin-sharing/src/sharing-plugin.ts index 026d033cfc5..ce6e41ebaf8 100644 --- a/packages/plugins/plugin-sharing/src/sharing-plugin.ts +++ b/packages/plugins/plugin-sharing/src/sharing-plugin.ts @@ -869,6 +869,13 @@ export class SharingServicePlugin implements Plugin { canManageShares: this.service ? (o, r, c) => this.service!.canManageShares(o, r, c as any) : undefined, + // [ADR-0111 D8 rule 1, ruling 5950188467 (A′)] The record's owner and + // an explicit Modify-All holder may mint where the visibility read + // refuses them: `canManageShares`' own owner and bypass branches, + // without its hierarchy-depth branch. + canMintWithoutVisibility: this.service + ? (o, r, c) => this.service!.canMintWithoutVisibility(o, r, c as any) + : undefined, }); ctx.registerService('shareLinks', this.linkService); diff --git a/packages/plugins/plugin-sharing/src/sharing-service.ts b/packages/plugins/plugin-sharing/src/sharing-service.ts index f25a41f4fb5..a726d24d95f 100644 --- a/packages/plugins/plugin-sharing/src/sharing-service.ts +++ b/packages/plugins/plugin-sharing/src/sharing-service.ts @@ -3,6 +3,7 @@ import type { AuthoredRowWriteOperation, AuthoredRowWriteVerdict, + ExplainAccessRequest, ISharingService, IHierarchyScopeResolver, RecordShare, @@ -13,6 +14,7 @@ import type { import { normalizeTenancyPosture, postureEnforcesWall, + type ExplainDecision, type TenancyPosture, } from '@objectstack/spec/security'; // [#7136] Every enforcement method below takes the FULL `resolveAuthzContext` @@ -287,6 +289,20 @@ export interface SharingSecurityProbe { object: string, context: unknown, ): Promise<'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org'>; + /** + * [ADR-0111 D8 rule 1 — the capability hard stop] `ISecurityService.explain`, + * a declared (not optional) contract method, read here for ONE layer of its + * declared report: `required_permissions`, the ADR-0066 D3 capability + * AND-gate. The explain engine computes that layer with the middleware's own + * capability fold, so its `denies` is the refusal the read gate throws. + * Used by {@link SharingService.canMintWithoutVisibility} only. Absent while + * the service is present, a throw, or a report that does not carry the layer + * answers as a refusal. + */ + explain?( + request: ExplainAccessRequest, + callerContext?: unknown, + ): Promise>; } /** [#5103] The table whose orphans this service owns. */ @@ -365,6 +381,17 @@ export interface SharingServiceOptions { }; } +/** + * [ADR-0111 D1] What `SharingService.ownerOrBypass` concluded about a record: + * the caller owns it or holds the Modify-All bypass (`admit`), there is + * nothing to decide on (`refuse`), or neither branch applies and the record's + * `owner` is handed on to the DEPTH branch (`undecided`). + */ +type OwnerOrBypassVerdict = + | { readonly kind: 'admit' } + | { readonly kind: 'refuse' } + | { readonly kind: 'undecided'; readonly owner: unknown }; + /** * Default `ISharingService` implementation. * @@ -950,31 +977,15 @@ export class SharingService implements ISharingService { context: ExecutionContext, ): Promise { if (context?.isSystem) return true; - if (!object || !recordId || !context?.userId) return false; - - // Ownership — read under system context so field-level masking cannot - // hide the owner column from the decision itself. Keep the owner value: - // the DEPTH branch below reuses it rather than re-reading the row. - let owner: unknown; - try { - const rows = await this.engine.find(object, { - where: { id: recordId }, - fields: ['id', OWNER_FIELD], - limit: 1, - context: SYSTEM_CTX, - }); - const row: any = Array.isArray(rows) ? rows[0] : undefined; - if (!row) return false; - owner = row[OWNER_FIELD]; - if (owner != null && String(owner) === String(context.userId)) return true; - } catch { - return false; - } - // Modify All Data — the EXPLICIT bypass only (ADR-0111 D1/D2; never the - // effective write scope, whose unmatched-object case fails open to 'org'). - // [#4647] Shared with `canEdit`/`canDelete` so the three gates cannot drift. - if (await this.hasModifyAllBypass(object, context)) return true; + // The record owner and the Modify-All bypass, read in `ownerOrBypass` — + // the two branches this gate shares with mint authority + // (`canMintWithoutVisibility`). A verdict there is final; `undecided` + // hands the owner value on, so the DEPTH branch below does not re-read + // the row. + const direct = await this.ownerOrBypass(object, recordId, context); + if (direct.kind !== 'undecided') return direct.kind === 'admit'; + const owner = direct.owner; const probe = this.securityService?.(); @@ -1001,6 +1012,148 @@ export class SharingService implements ISharingService { return false; } + /** + * [ADR-0111 D8 rule 1 — ruling 5950188467, A′] May `context` mint a share + * link on `(object, recordId)` WITHOUT being able to see it? + * + * Mint authority is "visibility, or the record owner, or an explicit + * Modify-All bypass". The link service runs the visibility read itself; this + * answers the other two alternatives, and it answers them with + * {@link canManageShares}' own first two branches (`ownerOrBypass`) — the + * same owner column and the same `hasWriteBypass` probe, so there is one + * notion of ownership, not two. + * + * ## What it deliberately leaves out + * + * **The hierarchy-depth branch.** A manager whose write DEPTH covers the + * owner manages the record's shares (revoke, grant, list — D8 rule 2), but a + * link CREATES access, and that manager may hold write depth over a record + * the data door will not let them read. Admitting them would let minting + * outrun reading; so a hierarchy manager still needs visibility to mint. This + * method never calls `resolveWriteScope` or the hierarchy resolver. + * + * **A deployment that walls organizations.** Under the `group` / `isolated` + * postures the visibility read applies Layer 0 (ADR-0095 D1 / ADR-0105 D1), + * and neither alternative here knows the record's organization: the owner + * column outlives a membership, and `hasWriteBypass` is object-wide. So where + * a wall is in force both alternatives are withheld and visibility alone + * admits — a member who left an organization cannot mint a public link to a + * record they still own in it. The posture is the one + * {@link organizationScopeRequired} reads, fail-closed: an unresolvable + * posture counts as walled. + * + * **A capability the object requires.** When the caller lacks a capability + * the object's `requiredPermissions` demands for a read (ADR-0066 D3), the + * visibility read was refused by that AND-gate, and the gate is a hard stop: + * 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 who lacks it does not "already read everything". + * The verdict is the declared `required_permissions` layer of + * `ISecurityService.explain` (`capabilityGateRefusesRead`). + * + * Everything else fails CLOSED to `false`: a missing record, a + * principal-less context, a failed read or probe. + */ + async canMintWithoutVisibility( + object: string, + recordId: string, + context: ExecutionContext, + ): Promise { + if (this.organizationScopeRequired()) return false; + if ((await this.ownerOrBypass(object, recordId, context)).kind !== 'admit') return false; + // Asked last, and only of a principal an alternative admitted, so a + // refused stranger never pays for the explain walk. + return !(await this.capabilityGateRefusesRead(object, context)); + } + + /** + * [ADR-0111 D8 rule 1 — the capability hard stop] Does the ADR-0066 D3 + * capability AND-gate refuse `context` a READ of `object`? + * + * Answered by `ISecurityService.explain`, a declared contract method, from + * the one layer of its declared report that IS that gate: + * `required_permissions`. The explain engine computes the layer with the + * read middleware's own capability fold (the same `requiredPermissions` + * normalisation, the same held-capability union, the same ADR-0090 D10 + * delegator intersection), so `denies` there is the refusal the visibility + * read threw. It is NOT the owner-private CRUD refusal, which the same report + * attributes to `object_crud` with this layer `not_applicable` — which is + * what lets the hard stop leave the owner alternative standing on an object + * that requires no capability. + * + * `false` (the gate admits) 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 + * carrying any other verdict answers `true` — a stop. The one exception is a + * deployment with NO security service at all: nothing there enforces a + * capability gate, so no read was refused by one. + */ + private async capabilityGateRefusesRead( + object: string, + context: ExecutionContext, + ): Promise { + let probe: SharingSecurityProbe | null | undefined; + try { + probe = this.securityService?.(); + } catch { + return true; + } + if (!probe) return false; + if (typeof probe.explain !== 'function') return true; + try { + const decision = await probe.explain({ object, operation: 'read' }, context); + const gate = decision?.layers?.find((layer) => layer?.layer === 'required_permissions'); + return !(gate && (gate.verdict === 'neutral' || gate.verdict === 'not_applicable')); + } catch { + return true; + } + } + + /** + * [ADR-0111 D1] The record OWNER and the explicit Modify-All bypass — the + * branches {@link canManageShares} and {@link canMintWithoutVisibility} both + * read, written once. + * + * `admit`: the caller owns the record, or holds `modifyAllRecords` on the + * object. `refuse`: there is nothing to decide on — no object, record or + * user identity, no such record, or the owner read failed. `undecided`: the + * record exists and the caller is neither; `owner` is carried for the DEPTH + * branch, which only `canManageShares` consults. + * + * Ownership is read under the system context so field-level masking cannot + * hide the owner column from the decision itself. The bypass is the EXPLICIT + * one only (ADR-0111 D1/D2; never the effective write scope, whose + * unmatched-object case fails open to 'org'), shared with `canEdit` / + * `canDelete` [#4647] so the gates cannot drift. + */ + private async ownerOrBypass( + object: string, + recordId: string, + context: ExecutionContext, + ): Promise { + if (!object || !recordId || !context?.userId) return { kind: 'refuse' }; + + let owner: unknown; + try { + const rows = await this.engine.find(object, { + where: { id: recordId }, + fields: ['id', OWNER_FIELD], + limit: 1, + context: SYSTEM_CTX, + }); + const row: any = Array.isArray(rows) ? rows[0] : undefined; + if (!row) return { kind: 'refuse' }; + owner = row[OWNER_FIELD]; + if (owner != null && String(owner) === String(context.userId)) return { kind: 'admit' }; + } catch { + return { kind: 'refuse' }; + } + + if (await this.hasModifyAllBypass(object, context)) return { kind: 'admit' }; + return { kind: 'undecided', owner }; + } + /** * [ADR-0111 D5] Is `(object, recordId)` VISIBLE to the caller? Reads under * the CALLER's own context so the security RLS and sharing read filters diff --git a/packages/runtime/src/domains/share-links-enforcement-context.test.ts b/packages/runtime/src/domains/share-links-enforcement-context.test.ts index 244d3b8dca8..c43353d9be9 100644 --- a/packages/runtime/src/domains/share-links-enforcement-context.test.ts +++ b/packages/runtime/src/domains/share-links-enforcement-context.test.ts @@ -54,7 +54,7 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SHARE_LINK_SERVICE } from '@objectstack/spec/contracts'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { PermissionDeniedError, SecurityPlugin } from '@objectstack/plugin-security'; -import { ShareLinkService, registerShareLinkRoutes } from '@objectstack/plugin-sharing'; +import { ShareLinkService, SharingServicePlugin, registerShareLinkRoutes } from '@objectstack/plugin-sharing'; import { ApiErrorSchema, BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; import { BUILTIN_OPERATION_MESSAGES } from '@objectstack/spec/system'; import { apiErrorResponse } from '../error-envelope.js'; @@ -989,6 +989,7 @@ async function mintOnPluginDoor( engine: any, svc: ShareLinkService, envelope: ExecutionContext, + body: { object: string; recordId: string } = { object: OBJECT, recordId: RECORD }, ): Promise<{ status: number; body: any }> { const http = new RouteRecorder(); registerShareLinkRoutes(http, svc, engine, { contextFromRequest: () => envelope }); @@ -1004,7 +1005,7 @@ async function mintOnPluginDoor( const req: IHttpRequest = { params: {}, query: {}, - body: { object: OBJECT, recordId: RECORD }, + body, headers: {}, method: 'POST', path: '/api/v1/share-links', @@ -1046,3 +1047,367 @@ describe('[#21405] the plugin route door answers the same refusal with the same }, 30_000); } }); + +/** + * [#21329 — ADR-0111 D8 rule 1, ruling 5950188467 (A′)] Who may mint a link on + * an OWNER-PRIVATE object, read at BOTH doors. + * + * ## The object + * + * An analogue of the conversation object the card measured: `access.default: + * 'private'` (ADR-0066 D2), so the member baseline's `'*'` wildcard grant does + * not cover it and no member reads it through the data door; an `owner_id` the + * owner holds; and the `publicSharing` opt-in. On it the visibility read + * `createLink` runs refuses the OWNER too — the CRUD gate throws before any + * row is looked at — so on the visibility rule alone the owner could never + * share their own record. + * + * ## The ruled matrix + * + * "the owner mints on an owner-private object; a hierarchy manager without + * visibility is refused; a non-owner member is refused; Modify-All mints" — + * plus the anonymous resolve of the owner's link, which is what the mint is + * for. Each refusal is read as the pair (`status`, `code`) and against the + * STORE: a refused mint writes no row. + * + * ## The capability hard stop + * + * A second object is gated on a capability too (`requiredPermissions`, + * ADR-0066 D3). An owner who lacks it is refused there: neither alternative + * applies past that gate. The control is an owner who holds the capability and + * is refused only by the CRUD grant, and mints. + * + * ## What is real here + * + * Both doors' production entries — the dispatcher domain body and the plugin's + * `registerShareLinkRoutes` (via `mintOnPluginDoor` above) — over ONE link + * service; the whole `SecurityPlugin` (its CRUD gate, its `hasWriteBypass` and + * `resolveWriteScope` probes, booted over the same engine double); and the + * whole `SharingServicePlugin`, booted with `registerShareLinkRoutes: false`, + * the per-environment configuration for which the dispatcher domain is the + * only share-link surface. The plugin composes the link service itself, so the + * authority each case reaches is the production wiring, not a hand-assembled + * copy of it. DOUBLES: storage (`makeEngine` above) and the enterprise + * hierarchy resolver, which this open edition does not ship. + */ +describe('[#21329] mint authority on an owner-private object, at both doors (ruling A′)', () => { + const CONV = 'ai_conversations'; + const CONV_ID = 'conv_1'; + const OWNER = 'u_owner'; + const STRANGER = 'u_stranger'; + const ADMIN = 'u_admin'; + const MANAGER = 'u_manager'; + /** Owns a record on the capability-gated object AND holds its capability. */ + const CAPABLE_OWNER = 'u_capable_owner'; + /** Owner-private AND capability-gated (ADR-0066 D3): a read needs `view_vault`. */ + const VAULT = 'vault_notes'; + + const CONVERSATION_SCHEMA = { + name: CONV, + access: { default: 'private' }, + fields: { + id: { name: 'id' }, + title: { name: 'title' }, + owner_id: { name: 'owner_id' }, + }, + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + }; + + /** The member baseline: a `'*'` wildcard grant, the shape `member_default` has. */ + const MEMBER_BASELINE: PermissionSet = PermissionSetSchema.parse({ + name: 'conv_member_baseline', + label: 'Member baseline (wildcard grant)', + objects: { '*': { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + }); + + /** Modify All on the wildcard, which a private object honours (the super-user bits). */ + const MODIFY_ALL: PermissionSet = PermissionSetSchema.parse({ + name: 'conv_modify_all', + label: 'Modify All', + objects: { + '*': { + allowRead: true, + allowCreate: true, + allowEdit: true, + allowDelete: true, + viewAllRecords: true, + modifyAllRecords: true, + }, + }, + }); + + /** + * A hierarchy manager: WRITE depth `unit` on the object and no read grant — + * a principal `canManageShares` admits (ADR-0111 D1 DEPTH) on a record the + * data door will not let them read. + */ + const UNIT_WRITER: PermissionSet = PermissionSetSchema.parse({ + name: 'conv_unit_writer', + label: 'Unit-depth writer, no read', + objects: { [CONV]: { allowEdit: true, writeScope: 'unit' } }, + }); + + /** The object a capability gates, on top of being owner-private. */ + const VAULT_SCHEMA = { + name: VAULT, + access: { default: 'private' }, + requiredPermissions: ['view_vault'], + fields: { + id: { name: 'id' }, + title: { name: 'title' }, + owner_id: { name: 'owner_id' }, + }, + publicSharing: { enabled: true, allowedAudiences: ['link_only'], allowedPermissions: ['view'] }, + }; + + /** The capability, and nothing else: no object grant rides with it. */ + const VAULT_CAPABLE: PermissionSet = PermissionSetSchema.parse({ + name: 'conv_vault_capable', + label: 'Holds view_vault', + objects: {}, + systemPermissions: ['view_vault'], + }); + + const SETS = [MEMBER_BASELINE, MODIFY_ALL, UNIT_WRITER, VAULT_CAPABLE]; + + /** The envelope `resolveExecutionContext` assembles for a human member of org A. */ + const principal = (userId: string, permissions: string[]): ExecutionContext => + ({ + userId, + tenantId: ORG_A, + email: `${userId}@example.com`, + isSystem: false, + principalKind: 'human', + posture: 'MEMBER', + positions: [], + permissions, + systemPermissions: [], + org_user_ids: [userId], + accessible_org_ids: [ORG_A], + }) as unknown as ExecutionContext; + + const owner = () => principal(OWNER, ['conv_member_baseline']); + const stranger = () => principal(STRANGER, ['conv_member_baseline']); + const admin = () => principal(ADMIN, ['conv_member_baseline', 'conv_modify_all']); + const manager = () => principal(MANAGER, ['conv_member_baseline', 'conv_unit_writer']); + const capableOwner = () => principal(CAPABLE_OWNER, ['conv_member_baseline', 'conv_vault_capable']); + + interface World { + tables: Record; + sharing: any; + mint(as: ExecutionContext): Promise<{ status: number; body: any }>; + /** The same mint through the plugin's route door. */ + mintOnPlugin(as: ExecutionContext): Promise<{ status: number; body: any }>; + /** The same mint on the capability-gated object, at each door. */ + mintVault(recordId: string, as: ExecutionContext): Promise<{ status: number; body: any }>; + mintVaultOnPlugin(recordId: string, as: ExecutionContext): Promise<{ status: number; body: any }>; + /** A data-door read of a vault record under `as`. */ + readVault(recordId: string, as: ExecutionContext): Promise; + resolve(token: string): Promise<{ status: number; body: any }>; + revoke(idOrToken: string, as: ExecutionContext): Promise<{ status: number; body: any }>; + /** A data-door read of the record under `as` — the visibility leg itself. */ + read(as: ExecutionContext): Promise; + resolveWriteScopeCalls(): number; + } + + async function bootWorld(): Promise { + const tables: Record = { + [CONV]: [{ id: CONV_ID, title: 'My chat', owner_id: OWNER, organization_id: ORG_A }], + [VAULT]: [ + { id: 'vault_owner', title: 'Owner\'s note', owner_id: OWNER, organization_id: ORG_A }, + { id: 'vault_capable', title: 'Capable owner\'s note', owner_id: CAPABLE_OWNER, organization_id: ORG_A }, + ], + sys_share_link: [], + sys_permission_set: [], + }; + const engine = makeEngine(tables, { [CONV]: CONVERSATION_SCHEMA, [VAULT]: VAULT_SCHEMA }); + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { get: async () => null, list: async () => SETS }, + // Cloud's topology: one database per environment, no organization wall. + tenancy: { posture: 'single' }, + // The enterprise seam, doubled: the manager's `unit` covers the owner. + 'hierarchy-scope-resolver': { + resolveOwnerIds: async (c: any, scope: string) => + c?.userId === MANAGER && scope === 'unit' ? [MANAGER, OWNER] : [String(c?.userId)], + }, + }; + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const getService = (name: string) => { + if (name in services) return services[name]; + throw new Error(`service not registered: ${name}`); + }; + const registerService = (name: string, service: unknown) => { services[name] = service; }; + + const securityCtx: any = { logger, hook: vi.fn(), registerService, getService }; + const security = new SecurityPlugin({ + defaultPermissionSets: SETS, + fallbackPermissionSet: 'conv_member_baseline', + }); + await security.init(securityCtx); + await security.start(securityCtx); + + // Count the DEPTH probe, so a mint that consults it is visible. + let writeScopeCalls = 0; + const realResolveWriteScope = services.security.resolveWriteScope.bind(services.security); + services.security.resolveWriteScope = (...args: unknown[]) => { + writeScopeCalls += 1; + return realResolveWriteScope(...args); + }; + + const hooks: Record Promise | void>> = {}; + const sharingCtx: any = { + logger, + hook: (event: string, handler: () => Promise | void) => { (hooks[event] ??= []).push(handler); }, + registerService, + getService, + }; + const plugin = new SharingServicePlugin({ enforce: false, registerShareLinkRoutes: false }); + await plugin.init(sharingCtx); + await plugin.start(sharingCtx); + for (const handler of hooks['kernel:ready'] ?? []) await handler(); + + const svc = services[SHARE_LINK_SERVICE]; + if (!svc) throw new Error('the sharing plugin registered no share-link service'); + const deps = makeDeps(engine, svc); + const drive = async ( + subPath: string, + method: string, + body: unknown, + as: ExecutionContext | undefined, + ): Promise<{ status: number; body: any }> => { + const res = await handleShareLinksRequest(deps, subPath, method, body, {}, httpContext(as)); + if (!res.handled || !res.response) throw new Error(`${method} /share-links${subPath} was not handled`); + return res.response as { status: number; body: any }; + }; + + return { + tables, + sharing: services.sharing, + mint: (as) => drive('', 'POST', { object: CONV, recordId: CONV_ID }, as), + mintOnPlugin: (as) => mintOnPluginDoor(engine, svc, as, { object: CONV, recordId: CONV_ID }), + mintVault: (recordId, as) => drive('', 'POST', { object: VAULT, recordId }, as), + mintVaultOnPlugin: (recordId, as) => mintOnPluginDoor(engine, svc, as, { object: VAULT, recordId }), + readVault: async (recordId, as) => engine.find(VAULT, { where: { id: recordId }, limit: 1, context: as }), + resolve: (token) => drive(`/${token}/resolve`, 'GET', undefined, undefined), + revoke: (idOrToken, as) => drive(`/${idOrToken}`, 'DELETE', undefined, as), + read: async (as) => engine.find(CONV, { where: { id: CONV_ID }, limit: 1, context: as }), + resolveWriteScopeCalls: () => writeScopeCalls, + }; + } + + it('[persona] the object is owner-private: the owner\'s own data-door read is refused at the CRUD gate', async () => { + const w = await bootWorld(); + // The visibility leg is CLOSED for the owner — without this, a 201 below + // would prove nothing about the owner branch. + await expect(w.read(owner())).rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + await expect(w.read(stranger())).rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + await expect(w.read(manager())).rejects.toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + // The Modify-All holder reads it: the super-user bits are honoured on a private object. + expect(await w.read(admin())).toHaveLength(1); + }, 30_000); + + it('[owner] the owner mints a link on their own record at both doors, and an anonymous holder resolves it', async () => { + const w = await bootWorld(); + const res = await w.mint(owner()); + const plugin = await w.mintOnPlugin(owner()); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.data).toMatchObject({ object_name: CONV, record_id: CONV_ID, created_by: OWNER }); + expect(plugin.status, JSON.stringify(plugin.body)).toBe(201); + expect(plugin.body.data).toMatchObject({ object_name: CONV, record_id: CONV_ID, created_by: OWNER }); + expect(w.tables.sys_share_link.map((r) => r.created_by)).toEqual([OWNER, OWNER]); + + const resolved = await w.resolve(String(res.body.data.token)); + expect(resolved.status, JSON.stringify(resolved.body)).toBe(200); + expect(resolved.body.data.record).toMatchObject({ id: CONV_ID, title: 'My chat' }); + }, 30_000); + + it('[stranger] a non-owner member is refused with the visibility read\'s own refusal at both doors, and nothing lands', async () => { + const w = await bootWorld(); + const res = await w.mint(stranger()); + const plugin = await w.mintOnPlugin(stranger()); + + expect(res.status).toBe(403); + expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED'); + expect(plugin.status, JSON.stringify(plugin.body)).toBe(403); + expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + expect(w.tables.sys_share_link).toEqual([]); + }, 30_000); + + it('[modify-all] a Modify-All holder mints at both doors', async () => { + const w = await bootWorld(); + const res = await w.mint(admin()); + const plugin = await w.mintOnPlugin(admin()); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(res.body.data).toMatchObject({ object_name: CONV, record_id: CONV_ID, created_by: ADMIN }); + expect(plugin.status, JSON.stringify(plugin.body)).toBe(201); + expect(w.tables.sys_share_link.map((r) => r.created_by)).toEqual([ADMIN, ADMIN]); + }, 30_000); + + it('[manager] a hierarchy manager without visibility is refused, though they ARE a share-manager of the record', async () => { + const w = await bootWorld(); + + // The control: this principal holds ADR-0111 D1 DEPTH authority over the + // record. Without it the refusal below could be "not a manager at all". + expect(await w.sharing.canManageShares(CONV, CONV_ID, manager())).toBe(true); + const callsBefore = w.resolveWriteScopeCalls(); + + const res = await w.mint(manager()); + const plugin = await w.mintOnPlugin(manager()); + expect(res.status).toBe(403); + expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED'); + expect(plugin.status, JSON.stringify(plugin.body)).toBe(403); + expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + expect(w.tables.sys_share_link).toEqual([]); + // The DEPTH branch is not part of mint authority at all: minting never + // asked for the caller's write scope. + expect(w.resolveWriteScopeCalls(), 'the mint consulted the hierarchy-depth probe').toBe(callsBefore); + + // ...while their revoke authority over the record (D8 rule 2) stands. + const minted = await w.mint(owner()); + expect(minted.status, JSON.stringify(minted.body)).toBe(201); + const revoked = await w.revoke(String(minted.body.data.id), manager()); + expect(revoked.status, JSON.stringify(revoked.body)).toBe(200); + expect(w.tables.sys_share_link[0]?.revoked_at).toBeTruthy(); + }, 30_000); + it('[capability persona] the vault refuses the owner at the CAPABILITY gate, and the capable owner only at the CRUD grant', async () => { + const w = await bootWorld(); + const ownerRead = await w.readVault('vault_owner', owner()).then(() => null, (e) => e); + expect(ownerRead).toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(ownerRead?.details?.missingPermissions, 'the owner lacks view_vault').toEqual(['view_vault']); + const capableRead = await w.readVault('vault_capable', capableOwner()).then(() => null, (e) => e); + expect(capableRead).toMatchObject({ code: 'PERMISSION_DENIED', statusCode: 403 }); + expect(capableRead?.details?.missingPermissions, 'the capable owner is refused by the CRUD grant, not the capability').toBeUndefined(); + }, 30_000); + + it('[capability] an owner lacking the object\'s required capability is refused with that refusal at both doors, and nothing lands', async () => { + const w = await bootWorld(); + const res = await w.mintVault('vault_owner', owner()); + const plugin = await w.mintVaultOnPlugin('vault_owner', owner()); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(expectDeclaredEnvelope(res).code).toBe('PERMISSION_DENIED'); + // The wire carries the refusal's user-facing sentence only — which gate + // refused (the capability's `missingPermissions`) stays off the wire, so + // that half is pinned beside the service, on the rejection itself. + expect(res.body.error.message).toBe(BUILTIN_OPERATION_MESSAGES.en.permission_denied); + expect(plugin.status, JSON.stringify(plugin.body)).toBe(403); + expect(plugin.body).toMatchObject({ success: false, error: { code: 'PERMISSION_DENIED' } }); + expect(w.tables.sys_share_link).toEqual([]); + // The owner alternative itself holds; the hard stop is what refused. + expect(await w.sharing.canManageShares(VAULT, 'vault_owner', owner())).toBe(true); + }, 30_000); + + it('[capability control] the owner who HOLDS the capability mints on the same object at both doors', async () => { + const w = await bootWorld(); + const res = await w.mintVault('vault_capable', capableOwner()); + const plugin = await w.mintVaultOnPlugin('vault_capable', capableOwner()); + + expect(res.status, JSON.stringify(res.body)).toBe(201); + expect(plugin.status, JSON.stringify(plugin.body)).toBe(201); + expect(w.tables.sys_share_link.map((r) => r.created_by)).toEqual([CAPABLE_OWNER, CAPABLE_OWNER]); + }, 30_000); +}); diff --git a/packages/spec/src/contracts/share-link-service.ts b/packages/spec/src/contracts/share-link-service.ts index 4ab88390b73..a3eb1307342 100644 --- a/packages/spec/src/contracts/share-link-service.ts +++ b/packages/spec/src/contracts/share-link-service.ts @@ -219,9 +219,20 @@ export interface IShareLinkService { * Mint a new link. Throws when the object is not opt-in or limits are exceeded. * * ENFORCEMENT PATH: implementations re-read the target record under - * `context` ([Finding-2] — you may only link-share a record you can - * yourself see), so `context` must be the caller's complete resolved - * envelope. A trimmed one silently changes the verdict of that read. + * `context`, so `context` must be the caller's complete resolved envelope. A + * trimmed one silently changes the verdict of that read. + * + * WHO MAY MINT (ADR-0111 D8 rule 1): the object's `publicSharing` opt-in, + * checked first; then authority over the record — that read SEES it + * ([Finding-2]), OR the caller is the record's owner, OR holds an explicit + * Modify-All (`modifyAllRecords`) bypass on the object; then + * `publicSharing.eligibility`, checked last. A hierarchy manager who manages + * the record's shares (revoke, grant, list) but cannot see it is NOT + * admitted: a link creates access. The owner and Modify-All alternatives are + * withheld where an organization wall is in force (the `group` / `isolated` + * tenancy postures), and they never apply past a capability the object + * requires (`requiredPermissions`, ADR-0066 D3). A caller refused on those + * grounds is answered with the read's own refusal. */ createLink(input: CreateShareLinkInput, context: ExecutionContext): Promise; diff --git a/packages/spec/src/contracts/sharing-service.ts b/packages/spec/src/contracts/sharing-service.ts index 745264b6119..c7ed21f8473 100644 --- a/packages/spec/src/contracts/sharing-service.ts +++ b/packages/spec/src/contracts/sharing-service.ts @@ -354,14 +354,20 @@ export interface ISharingService { /** * [ADR-0111 D1] May the principal in `context` MANAGE shares (grant / revoke * / list) on `(object, recordId)`? True for system context, the record's - * owner, and holders of the super-user write bypass (`modifyAllRecords`, - * probed via the late-bound security service). **Fails closed**: no security - * service → owner-only; unknown record / principal-less context → `false`. + * owner, holders of the super-user write bypass (`modifyAllRecords`, probed + * via the late-bound security service), and — the ADR-0111 D1 DEPTH + * extension — a hierarchy manager whose effective WRITE scope (`unit` / + * `unit_and_below` / `own_and_reports`, from + * `ISecurityService.resolveWriteScope`) covers the record's owner through the + * enterprise hierarchy resolver. **Fails closed**: no security service → + * owner-only; no hierarchy resolver → owner + Modify-All; unknown record / + * principal-less context → `false`. * * This is the single gate every manual share-management operation consults — * enforcement lives in the SERVICE, so every caller (REST or otherwise) is - * covered. The DEPTH extension (hierarchy managers) is a named ADR-0111 - * direction, not implemented here. + * covered. It is NOT mint authority for share links: ADR-0111 D8 rule 1 + * admits only its owner and Modify-All branches beside visibility, never the + * hierarchy branch (see `IShareLinkService.createLink`). */ canManageShares( object: string,