Skip to content

Commit 1ea6bce

Browse files
authored
feat(sharing): hierarchy managers may manage shares within their write DEPTH (ADR-0111 D1 DEPTH, framework) (#3997)
canManageShares gains a DEPTH branch: a hierarchy manager whose effective write scope (unit / unit_and_below / own_and_reports) covers the record's owner may manage its shares, via the new ISecurityService.resolveWriteScope probe + the enterprise hierarchy-scope-resolver. 'org' from the probe is ignored (doubles as the fail-open default; org management stays gated by explicit modifyAllRecords). Fails closed to owner + Modify-All without the resolver.
1 parent c6c0d05 commit 1ea6bce

7 files changed

Lines changed: 194 additions & 14 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-sharing": minor
4+
"@objectstack/plugin-security": minor
5+
---
6+
7+
feat(sharing): hierarchy managers may manage shares within their write DEPTH (ADR-0111 D1 DEPTH)
8+
9+
`canManageShares` gains its named DEPTH extension: a caller whose effective
10+
WRITE scope on the object is a hierarchy scope (`unit` / `unit_and_below` /
11+
`own_and_reports`) may now manage shares on a record whose owner falls within
12+
that scope's owner set — the same set the write filter and `canEdit` already
13+
honour, resolved by the enterprise `hierarchy-scope-resolver`. This lets a
14+
manager grant/revoke/list shares on a subordinate's record, matching
15+
Salesforce (roles above the owner) and Dataverse (the `Share` privilege's BU
16+
depth), without expanding the MVP owner + Modify-All authority.
17+
18+
- New `ISecurityService.resolveWriteScope(object, context)` — the effective
19+
write scope, resolved by the same evaluator the CRUD middleware uses; fails
20+
closed to `own`. Mirrored on the sharing plugin's structural probe.
21+
- The gate honours only the three hierarchy scopes. `org` from the probe is
22+
deliberately ignored: it means both a genuine Modify-All holder (already
23+
granted via `hasWriteBypass`) AND the fail-OPEN "no permission set mentions
24+
this object" default, so honouring it here would reopen the hole
25+
`hasWriteBypass` was chosen to avoid.
26+
- Fails closed with no security service or no enterprise resolver — the open
27+
edition stays owner + Modify-All, exactly as before.

content/docs/kernel/runtime-services/sharing-service.mdx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,13 @@ services.sharing.listShares(object: string, recordId: string, context: SharingEx
2424

2525
`grant` / `revoke` / `listShares` are **management operations**, enforced in the
2626
service for every non-system caller: the caller must hold `canManageShares` on
27-
the record — its owner, a holder of Modify All Data on the object, or system
28-
context. A deployment without `@objectstack/plugin-security` fails closed to
29-
owner-only. Pass `{ isSystem: true }` only from platform-internal machinery.
27+
the record — its **owner**, a holder of **Modify All Data**, a **hierarchy
28+
manager** whose effective write DEPTH (`unit` / `unit_and_below` /
29+
`own_and_reports`) covers the record's owner (via the enterprise
30+
`hierarchy-scope-resolver`), or **system** context. A deployment without
31+
`@objectstack/plugin-security` fails closed to owner-only; without the
32+
enterprise resolver the DEPTH path is inert and authority stays owner +
33+
Modify-All. Pass `{ isSystem: true }` only from platform-internal machinery.
3034

3135
## The verb boundary (ADR-0111 D3)
3236

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

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

3-
**Status**: Accepted (2026-07-30) — **P0 + P1 + D8 (framework) implemented**. P0 (D1/D2/D4/D5/D6/D7/D9): `canManageShares` + `hasWriteBypass`, verified by the #3902 Mallory reproduction. P1 (D3, the verb boundary): `canDelete` + verb-split `buildWriteFilter` in `plugin-sharing/src/sharing-service.ts`, routed by the middleware and `/security/explain`, verified by the "edit share cannot delete" suite. D8 (share-link re-share): `ShareLinkService.revokeLink` now admits a record share-manager via the late-bound `canManageShares` probe, and the mint-authority ruling (publicSharing opt-in + visibility) is enforced as before — verified in `share-link-service.test.ts`. **Still open**: the cloud-side D8 wiring (`HttpDispatcher.handleShareLinks` + a `.objectstack-sha` bump, in `objectstack-ai/cloud`) and the DEPTH management extension (D1 D-future) — the follow-ups this ADR's rollout section names.
3+
**Status**: Accepted (2026-07-30) — **P0 + P1 + D8 (framework) implemented**. P0 (D1/D2/D4/D5/D6/D7/D9): `canManageShares` + `hasWriteBypass`, verified by the #3902 Mallory reproduction. P1 (D3, the verb boundary): `canDelete` + verb-split `buildWriteFilter` in `plugin-sharing/src/sharing-service.ts`, routed by the middleware and `/security/explain`, verified by the "edit share cannot delete" suite. D8 (share-link re-share): `ShareLinkService.revokeLink` now admits a record share-manager via the late-bound `canManageShares` probe, and the mint-authority ruling (publicSharing opt-in + visibility) is enforced as before — verified in `share-link-service.test.ts`. D1 DEPTH extension: `canManageShares` now admits a hierarchy manager whose effective write scope covers the record's owner, via the new `ISecurityService.resolveWriteScope` probe + the enterprise `hierarchy-scope-resolver` (framework side, fails closed to owner+Modify-All without the resolver). **Still open**: the cloud-side wiring for both D8 and the DEPTH extension (`HttpDispatcher.handleShareLinks` + a `.objectstack-sha` bump + `security-enterprise` integration tests, in `objectstack-ai/cloud`) — batched into one framework-SHA bump per this ADR's rollout.
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a security property that parses but enforces nothing is worse than absent), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (DEPTH scopes + the `sys_record_share` / `sys_sharing_rule` split), [ADR-0066](./0066-unified-authorization-model.md) (unified capability model; `modifyAllRecords` super-user bit), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata — a persisted share level or recipient type that no gate consults is exactly this), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D1 secure-default OWD, D4 retired aliases, D10 delegated identity intersection), [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (time-boxed grants — the lifecycle axis this ADR deliberately does not re-open)
66
**Consumers**: `@objectstack/plugin-sharing` (`sharing-service.ts`, `sharing-rule-service.ts`, `share-link-service.ts`, `sharing-plugin.ts`), `@objectstack/plugin-security` (`ISecurityService` — a write-bypass probe), `@objectstack/rest` (`rest-server.ts` sharing / sharing-rule / share-link routes), `@objectstack/spec` (`contracts/sharing-service.ts`, `security/capabilities.ts`)
@@ -90,7 +90,7 @@ Introduce `canManageShares(object, recordId, context): Promise<boolean>` on `ISh
9090

9191
**Explicitly not** `getEffectiveScope(...) === 'org'`: that helper returns `'org'` for the "no permission set even mentions this object" case (a compatibility fail-*open* baked into the read path). Reusing it as a management gate would be a fresh hole. Management authority keys off the **explicit** `modifyAllRecords` bit only.
9292

93-
**DEPTH is a named direction, not in the MVP.** Salesforce (roles above the owner) and Dataverse (the `Share` privilege's BU depth) both let hierarchy managers share subordinates' records. ObjectStack's `__writeScope` (`unit` / `unit_and_below` / `own_and_reports`) is computed today only inside the engine middleware, not on the REST management path; plumbing it here is real work behind the enterprise `hierarchy-scope-resolver`. The MVP is owner + `modifyAllRecords`; D-future records the DEPTH extension so the model has a place for it.
93+
**DEPTH — landed after the MVP.** Salesforce (roles above the owner) and Dataverse (the `Share` privilege's BU depth) both let hierarchy managers share subordinates' records. The MVP shipped as owner + `modifyAllRecords`; the DEPTH extension then landed on the framework side: rather than reuse the middleware-only `__writeScope` (absent on the REST management path), `canManageShares` resolves the caller's effective write scope through a new `ISecurityService.resolveWriteScope` probe and, for a hierarchy scope only, expands it via the enterprise `hierarchy-scope-resolver` (the same owner set the write filter uses). `org` from that probe is deliberately NOT honoured as manager authority — it doubles as the fail-open "no permission set matches" default — so org-wide management stays gated by the explicit `modifyAllRecords` bypass. Fails closed to owner + Modify-All when the resolver (open edition) or security service is absent.
9494

9595
**Grant may never exceed the granter's own level** (Dataverse's rule). A caller who may manage sharing but is not owner/super-user may only grant a level they themselves hold on the record. In the MVP this is trivially satisfied (only owner/super-user pass the gate, and both hold every level), but the constraint is stated now so the DEPTH extension inherits it rather than bolting it on later.
9696

packages/plugins/plugin-security/src/security-plugin.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -682,6 +682,27 @@ export class SecurityPlugin implements Plugin {
682682
return false;
683683
}
684684
},
685+
// [ADR-0111 D1 DEPTH] Effective WRITE scope — the DEPTH primitive behind
686+
// canManageShares' hierarchy-manager branch. Same evaluator the CRUD
687+
// write path uses; fails CLOSED to 'own' on error / principal-less /
688+
// on-behalf-of. Returns 'org' for system and for Modify-All holders —
689+
// AND for the fail-open unmatched-object case, which is why the sharing
690+
// gate treats 'org' from here as non-authoritative on its own.
691+
resolveWriteScope: async (object: string, context?: any): Promise<'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org'> => {
692+
if (context?.isSystem) return 'org';
693+
if (!context?.userId || context?.onBehalfOf?.userId) return 'own';
694+
try {
695+
const meta = await this.getObjectSecurityMeta(object);
696+
const sets = await this.resolvePermissionSetsForContext(context);
697+
return this.permissionEvaluator.getEffectiveScope('write', object, sets, { isPrivate: meta.isPrivate });
698+
} catch (e) {
699+
this.logger.warn?.(
700+
`[security] resolveWriteScope failed for object '${object}' (user ${context?.userId ?? 'unknown'}) — narrowing to 'own' (fail-closed)`,
701+
e instanceof Error ? e : new Error(String(e)),
702+
);
703+
return 'own';
704+
}
705+
},
685706
// [ADR-0046 §6.7] Effective permission-set NAMES for a caller — the
686707
// primitive the REST read layer needs to evaluate a permission-set-
687708
// gated book/doc audience ({ permissionSet: '…' }). Same resolution

packages/plugins/plugin-sharing/src/sharing-service.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,76 @@ describe('[ADR-0111 D1] SharingService.canManageShares', () => {
748748
expect(await svc.canManageShares('account', 'nope', { userId: 'alice' })).toBe(false);
749749
expect(await svc.canManageShares('account', 'a1', {})).toBe(false);
750750
});
751+
752+
// ── [ADR-0111 D1 DEPTH] hierarchy-manager authority ──────────────────
753+
// a1 is owned by alice. bob is alice's manager (own_and_reports covers alice);
754+
// carol is an unrelated peer. The enterprise resolver is stubbed.
755+
it('a hierarchy manager whose write DEPTH covers the owner may manage the record', async () => {
756+
const svc = new SharingService({
757+
engine,
758+
securityService: () => ({
759+
hasWriteBypass: async () => false,
760+
resolveWriteScope: async () => 'own_and_reports',
761+
}),
762+
hierarchyResolver: () => ({
763+
// bob manages alice; nobody else in bob's reports.
764+
resolveOwnerIds: async (ctx: any) => ctx.userId === 'bob' ? ['bob', 'alice'] : [ctx.userId],
765+
}),
766+
});
767+
expect(await svc.canManageShares('account', 'a1', { userId: 'bob' })).toBe(true);
768+
});
769+
770+
it('a peer with the same DEPTH scope but NOT covering the owner is denied', async () => {
771+
const svc = new SharingService({
772+
engine,
773+
securityService: () => ({
774+
hasWriteBypass: async () => false,
775+
resolveWriteScope: async () => 'unit',
776+
}),
777+
hierarchyResolver: () => ({
778+
resolveOwnerIds: async (ctx: any) => [ctx.userId], // carol's unit owner-set excludes alice
779+
}),
780+
});
781+
expect(await svc.canManageShares('account', 'a1', { userId: 'carol' })).toBe(false);
782+
});
783+
784+
it("a probe reporting 'org' does NOT widen management (fail-open guard — only Modify All via hasWriteBypass grants org)", async () => {
785+
const svc = new SharingService({
786+
engine,
787+
securityService: () => ({
788+
hasWriteBypass: async () => false, // NOT a real Modify-All holder
789+
resolveWriteScope: async () => 'org', // the fail-open unmatched-object default
790+
}),
791+
// A resolver that would (wrongly) return everyone if consulted.
792+
hierarchyResolver: () => ({ resolveOwnerIds: async () => ['alice', 'bob', 'carol'] }),
793+
});
794+
expect(await svc.canManageShares('account', 'a1', { userId: 'stranger' })).toBe(false);
795+
});
796+
797+
it("'own' scope adds nothing beyond the ownership check (non-owner denied)", async () => {
798+
const svc = new SharingService({
799+
engine,
800+
securityService: () => ({
801+
hasWriteBypass: async () => false,
802+
resolveWriteScope: async () => 'own',
803+
}),
804+
hierarchyResolver: () => ({ resolveOwnerIds: async (ctx: any) => [ctx.userId] }),
805+
});
806+
expect(await svc.canManageShares('account', 'a1', { userId: 'mallory' })).toBe(false);
807+
});
808+
809+
it('DEPTH branch needs the enterprise resolver — a manager scope with no resolver falls back to owner-only', async () => {
810+
const svc = new SharingService({
811+
engine,
812+
securityService: () => ({
813+
hasWriteBypass: async () => false,
814+
resolveWriteScope: async () => 'unit',
815+
}),
816+
// No hierarchyResolver → resolveOwnerScopeIds fails closed to [me], which
817+
// excludes alice, so bob cannot manage alice's record.
818+
});
819+
expect(await svc.canManageShares('account', 'a1', { userId: 'bob' })).toBe(false);
820+
});
751821
});
752822

753823
describe('[ADR-0111 D1/D4/D5] the #3902 Mallory reproduction', () => {

packages/plugins/plugin-sharing/src/sharing-service.ts

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ function hasOwnerField(schema: any): boolean {
8989
*/
9090
export interface SharingSecurityProbe {
9191
hasWriteBypass?(object: string, context: unknown): Promise<boolean>;
92+
/**
93+
* [ADR-0111 D1 DEPTH] The caller's effective WRITE scope on `object`
94+
* (`own` / `own_and_reports` / `unit` / `unit_and_below` / `org`), resolved
95+
* from their permission sets exactly as the CRUD middleware resolves it.
96+
* Used by {@link SharingService.canManageShares} to let a hierarchy manager
97+
* manage shares on records they can write by DEPTH. Fails closed to `own`.
98+
*/
99+
resolveWriteScope?(
100+
object: string,
101+
context: unknown,
102+
): Promise<'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org'>;
92103
}
93104

94105
export interface SharingServiceOptions {
@@ -356,10 +367,11 @@ export class SharingService implements ISharingService {
356367
* [ADR-0111 D1] May `context` MANAGE shares (grant / revoke / list) on
357368
* `(object, recordId)`? System → yes. Record owner → yes. Super-user write
358369
* bypass (`modifyAllRecords`, probed via the late-bound security service) →
359-
* yes. Everything else — a missing record, a principal-less context, a
360-
* probe failure, a deployment without plugin-security — fails CLOSED to
361-
* `false`. The DEPTH extension (hierarchy managers may share subordinates'
362-
* records) is a named ADR-0111 direction, deliberately not implemented here.
370+
* yes. [ADR-0111 D1 DEPTH] A HIERARCHY MANAGER whose write DEPTH
371+
* (`unit` / `unit_and_below` / `own_and_reports`) covers the record's owner →
372+
* yes. Everything else — a missing record, a principal-less context, a probe
373+
* failure, a deployment without plugin-security or the enterprise resolver —
374+
* fails CLOSED to `false`.
363375
*/
364376
async canManageShares(
365377
object: string,
@@ -370,7 +382,9 @@ export class SharingService implements ISharingService {
370382
if (!object || !recordId || !context?.userId) return false;
371383

372384
// Ownership — read under system context so field-level masking cannot
373-
// hide the owner column from the decision itself.
385+
// hide the owner column from the decision itself. Keep the owner value:
386+
// the DEPTH branch below reuses it rather than re-reading the row.
387+
let owner: unknown;
374388
try {
375389
const rows = await this.engine.find(object, {
376390
where: { id: recordId },
@@ -380,21 +394,43 @@ export class SharingService implements ISharingService {
380394
});
381395
const row: any = Array.isArray(rows) ? rows[0] : undefined;
382396
if (!row) return false;
383-
const owner = row[OWNER_FIELD];
397+
owner = row[OWNER_FIELD];
384398
if (owner != null && String(owner) === String(context.userId)) return true;
385399
} catch {
386400
return false;
387401
}
388402

403+
const probe = this.securityService?.();
404+
389405
// Modify All Data — the EXPLICIT bypass only (ADR-0111 D1/D2; never the
390406
// effective write scope, whose unmatched-object case fails open to 'org').
391407
try {
392-
const probe = this.securityService?.();
393408
if (probe && typeof probe.hasWriteBypass === 'function') {
394-
return (await probe.hasWriteBypass(object, context)) === true;
409+
if ((await probe.hasWriteBypass(object, context)) === true) return true;
395410
}
396411
} catch {
397-
/* fall through to deny */
412+
/* fall through */
413+
}
414+
415+
// [ADR-0111 D1 DEPTH] Hierarchy-manager authority: a caller whose effective
416+
// WRITE scope on this object is a HIERARCHY scope may manage shares on a
417+
// record whose owner falls within that scope's owner set (the same set the
418+
// write filter/canEdit use). Only the three hierarchy scopes widen here —
419+
// `own` adds nothing beyond the ownership check above, and `org` is
420+
// DELIBERATELY ignored: `resolveWriteScope` returns `org` both for a genuine
421+
// Modify-All holder (already handled) AND for the fail-OPEN
422+
// "no permission set mentions this object" case, so honouring `org` here
423+
// would reopen exactly the hole `hasWriteBypass` was chosen to avoid.
424+
if (owner != null && probe && typeof probe.resolveWriteScope === 'function') {
425+
try {
426+
const scope = await probe.resolveWriteScope(object, context);
427+
if (scope === 'unit' || scope === 'unit_and_below' || scope === 'own_and_reports') {
428+
const ownerIds = await this.resolveOwnerScopeIds(context, scope);
429+
if (ownerIds.includes(String(owner))) return true;
430+
}
431+
} catch {
432+
/* fall through to deny */
433+
}
398434
}
399435
return false;
400436
}

packages/spec/src/contracts/security-service.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,28 @@ export interface ISecurityService {
236236
*/
237237
hasWriteBypass(object: string, context?: SecurityContext): Promise<boolean>;
238238

239+
/**
240+
* [ADR-0111 D1 DEPTH] The caller's effective WRITE scope on `object` —
241+
* `own` / `own_and_reports` / `unit` / `unit_and_below` / `org` — resolved
242+
* from their permission sets exactly as the CRUD middleware's write path
243+
* resolves it (`getEffectiveScope('write', …)`).
244+
*
245+
* The sharing layer's management gate (`ISharingService.canManageShares`)
246+
* uses this to let a HIERARCHY MANAGER manage shares on records within their
247+
* DEPTH. Note the two meanings of `org`: a genuine `modifyAllRecords` holder,
248+
* AND the fail-OPEN "no permission set mentions this object" default — so a
249+
* caller of this method must treat `org` as authoritative ONLY when paired
250+
* with an explicit {@link hasWriteBypass} check, never on its own.
251+
*
252+
* **Fails CLOSED** to `own` (the narrowest scope) on a resolution error, a
253+
* principal-less context, or an on-behalf-of context (no D10 delegator
254+
* intersection on this path). A system context resolves to `org`.
255+
*/
256+
resolveWriteScope(
257+
object: string,
258+
context?: SecurityContext,
259+
): Promise<'own' | 'own_and_reports' | 'unit' | 'unit_and_below' | 'org'>;
260+
239261
/**
240262
* Explain WHY access is granted or denied — the decision plus the layers that
241263
* produced it (permission sets, object permissions, RLS, sharing, field mask).

0 commit comments

Comments
 (0)