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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/adr-0111-sharing-authorization-face.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-sharing": minor
"@objectstack/plugin-security": minor
"@objectstack/rest": minor
---

fix(sharing)!: the share-management surface gains the authorization layer it never had (ADR-0111 P0, #3902)

Record sharing shipped as a data layer with no authorization of its own: every
`/data/:object/:id/shares` and `/sharing/rules` route authenticated the caller
and then ran the service under `SYSTEM_CTX` — any signed-in user could revoke
anyone's share, enumerate who-can-see-what, write self-grants, and define /
evaluate org-wide sharing rules. ADR-0111's P0 rulings land here:

- **D1/D2** — `ISharingService.canManageShares(object, recordId, context)`:
system, the record's owner, or a holder of Modify All Data (probed via the
new fail-closed `ISecurityService.hasWriteBypass`). Enforced in the SERVICE,
so every caller is covered; without plugin-security it fails closed to
owner-only.
- **D4** — `revoke` is symmetric with grant, validates the share belongs to the
URL's record (`NOT_FOUND` on mismatch), and refuses non-`manual` rows
(`CONFLICT` — a rule-materialised grant would be resurrected by the next
reconcile).
- **D5** — `listShares` is management-gated (invisible record → `NOT_FOUND`,
visible-but-not-manager → `PERMISSION_DENIED`), and the open
`/data/sys_record_share` read surface is self-scoped: non-admin callers see
only rows naming them as recipient or grantor.
- **D6** — the whole `/sharing/rules` surface (list/create/get/delete/evaluate)
requires the new **`manage_sharing`** capability (D9; seeded into
`admin_full_access`, `manage_platform_settings` honoured as the legacy
equivalent), enforced in `SharingRuleService`.
- **D7** — no inert grants: `recipientType` is narrowed to `user` (the only
type any gate enforces), grants on objects the sharing gates never consult
(public model, no `owner_id`, bypass, `controlled_by_parent`) fail with
`SHARING_NOT_ENABLED` (422), and the manual upsert keys on
`(object, record, recipient, source)` so manual and rule rows coexist.

**Breaking** for callers that relied on the missing gate: unauthorized share
management now fails with 403/404/409/422 instead of silently succeeding, and
`ISharingService.revoke` gained an optional `scope` parameter. The verb
boundary (edit ≠ delete, ADR-0111 D3) is NOT in this change — it lands as the
separate P1.
2 changes: 1 addition & 1 deletion content/docs/kernel/index.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ The kernel is ObjectStack's runtime: it loads your metadata artifact, hosts plug
| API | Stability | What it does |
| :--- | :--- | :--- |
| [`services.data`](/docs/kernel/runtime-services/data-service) | stable | CRUD and queries with the caller's permission context |
| [`services.sharing`](/docs/kernel/runtime-services/sharing-service) | stable | `buildReadFilter`, `canEdit`, `grant`/`revoke`, `listShares` |
| [`services.sharing`](/docs/kernel/runtime-services/sharing-service) | stable | `buildReadFilter`, `canEdit`, `canManageShares`, `grant`/`revoke`, `listShares` |
| [`services.email`](/docs/kernel/runtime-services/email-service) | stable | `send`, `sendTemplate` |
| [`services.queue`](/docs/kernel/runtime-services/queue-service) | stable | Background work and queues |
| [`services.settings`](/docs/kernel/runtime-services/settings-service) | stable | App/environment settings |
Expand Down
19 changes: 16 additions & 3 deletions content/docs/kernel/runtime-services/sharing-service.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,21 +13,34 @@ description: Record-level sharing and editability checks.
```ts
services.sharing.buildReadFilter(object: string, context: SharingExecutionContext): Promise<unknown | null>
services.sharing.canEdit(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
services.sharing.canManageShares(object: string, recordId: string, context: SharingExecutionContext): Promise<boolean>
services.sharing.grant(input: GrantShareInput, context: SharingExecutionContext): Promise<RecordShare>
services.sharing.revoke(shareId: string, context: SharingExecutionContext): Promise<void>
services.sharing.revoke(shareId: string, context: SharingExecutionContext, scope?: { object: string; recordId: string }): Promise<void>
services.sharing.listShares(object: string, recordId: string, context: SharingExecutionContext): Promise<RecordShare[]>
```

## Management authority (ADR-0111)

`grant` / `revoke` / `listShares` are **management operations**, enforced in the
service for every non-system caller: the caller must hold `canManageShares` on
the record — its owner, a holder of Modify All Data on the object, or system
context. A deployment without `@objectstack/plugin-security` fails closed to
owner-only. Pass `{ isSystem: true }` only from platform-internal machinery.

## Returns

- `buildReadFilter`: `null` means unrestricted read; otherwise returns an engine filter
- `canEdit`: boolean decision
- `canEdit` / `canManageShares`: boolean decisions (they return `false` rather than throwing)
- `grant`/`listShares`: normalized `RecordShare` rows

## Typical Errors

- `FORBIDDEN` (403) — a write denied by the `canEdit` gate. Thrown by the sharing engine middleware; `canEdit` itself returns `false` rather than throwing.
- `VALIDATION_FAILED` — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`). `revoke` is otherwise a no-op when the share id is not found.
- `VALIDATION_FAILED` (400) — `grant`/`revoke` called without a required field (`object`, `recordId`, `recipientId`, or `shareId`), or `grant` with a non-`user` `recipientType` (only `user` rows are enforced by the gates; group/position recipients are delivered via sharing rules).
- `PERMISSION_DENIED` (403) — the caller does not hold `canManageShares` on the record (ADR-0111 D1).
- `NOT_FOUND` (404) — the record is missing **or not visible to the caller** (indistinguishable by design), or a `revoke` share id does not exist / does not belong to the `scope` record.
- `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead.
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, or `controlled_by_parent`).

## Example

Expand Down
12 changes: 12 additions & 0 deletions content/docs/permissions/sharing-rules.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -149,6 +149,18 @@ compiler. A condition the compiler cannot lower is **skipped and logged —
never seeded as a permissive match-all** (ADR-0049): a bad condition
under-shares rather than over-shares.

### Rule administration requires `manage_sharing` (ADR-0111 D6)

A sharing rule is an **org-wide grant generator**, so the whole programmatic
surface — `GET`/`POST` `{basePath}/sharing/rules`, `GET`/`DELETE`
`{basePath}/sharing/rules/:idOrName`, and `POST …/:idOrName/evaluate` — requires
the **`manage_sharing`** capability (seeded into `admin_full_access`;
`manage_platform_settings` is honoured as the legacy equivalent). The gate is
enforced in the service itself, not just at the route, so every caller is
covered; an unauthorized call fails with `403 PERMISSION_DENIED`. Boot
seeding, lifecycle hooks, and backfills run as system context and are
unaffected.

### There is no "share every record" rule

The predicate is **mandatory on every authoring path**, whether you declare
Expand Down
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
# ADR-0111: Record-share management authority and the verb boundary — sharing needs "who may manage a share" and "which verbs a level grants"

**Status**: Proposed (2026-07-29)
**Status**: Accepted (2026-07-30) — **P0 implemented** (D1/D2/D4/D5/D6/D7/D9: `canManageShares` + `hasWriteBypass` in `plugin-sharing/src/sharing-service.ts` / `plugin-security/src/security-plugin.ts`; verified by the #3902 Mallory reproduction in `plugin-sharing/src/sharing-service.test.ts` and the D6 gate suite in `sharing-rule.test.ts`). **D3 (verb boundary) and D8 (share-link rulings) are not yet implemented** — they land as the separate P1 / follow-up PRs this ADR's rollout section names.
**Deciders**: ObjectStack Protocol Architects
**Builds on**: [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove — a security property that parses but enforces nothing is worse than absent), [ADR-0057](./0057-erp-authorization-core-business-units-and-scope-depth.md) (DEPTH scopes + the `sys_record_share` / `sys_sharing_rule` split), [ADR-0066](./0066-unified-authorization-model.md) (unified capability model; `modifyAllRecords` super-user bit), [ADR-0078](./0078-no-silently-inert-metadata.md) (no silently inert metadata — a persisted share level or recipient type that no gate consults is exactly this), [ADR-0090](./0090-permission-model-v2-concept-convergence.md) (D1 secure-default OWD, D4 retired aliases, D10 delegated identity intersection), [ADR-0091](./0091-grant-lifecycle-and-recertification.md) (time-boxed grants — the lifecycle axis this ADR deliberately does not re-open)
**Consumers**: `@objectstack/plugin-sharing` (`sharing-service.ts`, `sharing-rule-service.ts`, `share-link-service.ts`, `sharing-plugin.ts`), `@objectstack/plugin-security` (`ISecurityService` — a write-bypass probe), `@objectstack/rest` (`rest-server.ts` sharing / sharing-rule / share-link routes), `@objectstack/spec` (`contracts/sharing-service.ts`, `security/capabilities.ts`)
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -121,6 +121,9 @@ const baseDefaultPermissionSets: PermissionSet[] = [
'manage_users',
'manage_metadata',
'manage_platform_settings',
// [ADR-0111 D9] Sharing administration — gates the sharing-rule surface
// and (in the DEPTH extension) non-owner share management.
'manage_sharing',
'setup.access',
'setup.write',
'studio.access',
Expand Down
30 changes: 29 additions & 1 deletion packages/plugins/plugin-security/src/security-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -660,6 +660,28 @@ export class SecurityPlugin implements Plugin {
// reaches the middleware as a plain `find` and `allowExport` would never
// be consulted — the REST export route asks HERE before it streams.
canExport: (object: string, context?: any) => this.canExport(object, context),
// [ADR-0111 D2] Super-user WRITE bypass probe — the management-authority
// primitive behind `ISharingService.canManageShares`. Explicit
// `modifyAllRecords` only (NOT the effective write scope, whose
// unmatched-object case fails open to 'org'); fails CLOSED on
// resolution errors, principal-less contexts, and on-behalf-of
// contexts (no D10 delegator intersection on this path).
hasWriteBypass: async (object: string, context?: any): Promise<boolean> => {
if (context?.isSystem) return true;
if (!context?.userId) return false;
if (context?.onBehalfOf?.userId) return false;
try {
const meta = await this.getObjectSecurityMeta(object);
const sets = await this.resolvePermissionSetsForContext(context);
return this.permissionEvaluator.hasSuperuserWriteBypass(object, sets, { isPrivate: meta.isPrivate });
} catch (e) {
this.logger.warn?.(
`[security] hasWriteBypass failed for object '${object}' (user ${context?.userId ?? 'unknown'}) — denying (fail-closed)`,
e instanceof Error ? e : new Error(String(e)),
);
return false;
}
},
// [ADR-0046 §6.7] Effective permission-set NAMES for a caller — the
// primitive the REST read layer needs to evaluate a permission-set-
// gated book/doc audience ({ permissionSet: '…' }). Same resolution
Expand DownExpand Up@@ -2179,7 +2201,13 @@ export class SecurityPlugin implements Plugin {
? { sharingReadFilter: (o: string, c: any) => sharing.buildReadFilter(o, c) }
: {}),
...(sharing && typeof sharing.listShares === 'function'
? { listRecordShares: (o: string, rid: string, c: any) => sharing.listShares(o, rid, c) }
// [ADR-0111 D5] listShares is now management-gated in the sharing
// service, but explain's own caller authorization already ran
// (explaining ANOTHER user requires `manage_users` — D12). Read the
// stored rows under system context so the record story keeps its
// share attribution when the EXPLAINED principal isn't a share
// manager — the exact behaviour this binding had before the gate.
? { listRecordShares: (o: string, rid: string) => sharing.listShares(o, rid, { isSystem: true }) }
: {}),
...(sharing && typeof sharing.canEdit === 'function'
? { canEditRecord: (o: string, rid: string, c: any) => sharing.canEdit(o, rid, c) }
Expand Down
30 changes: 30 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -390,6 +390,13 @@ export class SharingServicePlugin implements Plugin {
try { return ctx.getService<any>('hierarchy-scope-resolver'); }
catch { return null; }
},
// [ADR-0111 D1/D2] Late-bound security probe for canManageShares'
// Modify-All path. Absent (no plugin-security) → owner-only, fail
// closed — a degraded security stack never widens sharing authority.
securityService: () => {
try { return ctx.getService<any>('security'); }
catch { return null; }
},
});
ctx.registerService('sharing', this.service);

Expand DownExpand Up@@ -588,6 +595,29 @@ export function buildSharingMiddleware(service: SharingService): EngineMiddlewar

// READS — AND the visibility filter into the AST.
if (op === 'find' || op === 'findOne' || op === 'count' || op === 'aggregate') {
// [ADR-0111 D5] `sys_record_share` sits on the sharing BYPASS list (the
// enforcement queries must not recurse through their own gate), which
// used to leave its read surface wide open — any authenticated caller
// could enumerate every share row via `/data/sys_record_share` ("who can
// see what", plus the share ids the revoke gate protects). Non-system
// callers without sharing-admin capability are scoped to rows that NAME
// them (as recipient or grantor); principal-less callers see nothing.
// The Setup admin views hold `manage_sharing` (seeded into
// `admin_full_access`; `manage_platform_settings` honoured as the legacy
// gate those pages used) and keep the tenant-wide list.
if (ctx.object === 'sys_record_share' && !exec?.isSystem) {
const caps: string[] = Array.isArray(exec?.systemPermissions) ? exec.systemPermissions : [];
if (!caps.includes('manage_sharing') && !caps.includes('manage_platform_settings')) {
const selfScope = exec?.userId
? { $or: [{ recipient_id: exec.userId }, { granted_by: exec.userId }] }
: { id: '__deny_all__' };
const ast: any = ctx.ast ?? {};
ast.where = composeAnd(ast.where, selfScope);
ast.filter = composeAnd(ast.filter, selfScope);
ctx.ast = ast;
}
return next();
}
let filter = await service.buildReadFilter(ctx.object, exec ?? {});
// [ADR-0090 D10] Agent/service intersection on the OWD/sharing axis. When
// the principal acts on behalf of a user, the owner-match and record
Expand Down
26 changes: 26 additions & 0 deletions packages/plugins/plugin-sharing/src/sharing-rule-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -74,7 +74,29 @@ export class SharingRuleService implements ISharingRuleService {
this.logger = opts.logger;
}

/**
* [ADR-0111 D6] The sharing-rule surface is tenant-wide sharing
* ADMINISTRATION — a rule is an org-wide grant generator, and `evaluate`
* triggers materialisation, so every verb (list/get included) requires the
* `manage_sharing` capability. Enforced HERE, not at the route, so every
* caller is covered (#3902's widened finding: any signed-in user could
* define a broad-criteria rule naming themself and evaluate it into
* org-wide `sys_record_share` grants). `manage_platform_settings` is
* honoured as the legacy gate the Setup sharing pages used before
* `manage_sharing` existed. System contexts (boot seeding, hooks, backfills,
* the REST-independent plugin machinery) bypass.
*/
private assertCanManageRules(context: SharingExecutionContext): void {
if (context?.isSystem) return;
const caps = Array.isArray(context?.systemPermissions) ? context.systemPermissions : [];
if (caps.includes('manage_sharing') || caps.includes('manage_platform_settings')) return;
throw new Error(
'PERMISSION_DENIED: sharing-rule administration requires the manage_sharing capability (ADR-0111 D6)',
);
}

async defineRule(input: DefineSharingRuleInput, context: SharingExecutionContext): Promise<SharingRuleRow> {
this.assertCanManageRules(context);
if (!input.name) throw new Error('VALIDATION_FAILED: name is required');
if (!input.label) throw new Error('VALIDATION_FAILED: label is required');
if (!input.object) throw new Error('VALIDATION_FAILED: object is required');
Expand DownExpand Up@@ -176,6 +198,7 @@ export class SharingRuleService implements ISharingRuleService {
filter: { object?: string; activeOnly?: boolean },
context: SharingExecutionContext,
): Promise<SharingRuleRow[]> {
this.assertCanManageRules(context); // [ADR-0111 D6]
const where: any = {};
if (filter.object) where.object_name = filter.object;
if (filter.activeOnly) where.active = true;
Expand All@@ -191,6 +214,7 @@ export class SharingRuleService implements ISharingRuleService {
}

async getRule(idOrName: string, context: SharingExecutionContext): Promise<SharingRuleRow | null> {
this.assertCanManageRules(context); // [ADR-0111 D6]
if (!idOrName) return null;
const orgId = (context as any)?.organizationId ?? (context as any)?.tenantId;
const byId = await this.engine.find('sys_sharing_rule', {
Expand All@@ -209,6 +233,7 @@ export class SharingRuleService implements ISharingRuleService {
}

async deleteRule(idOrName: string, context: SharingExecutionContext): Promise<void> {
this.assertCanManageRules(context); // [ADR-0111 D6]
const row = await this.getRule(idOrName, context);
if (!row) return;
// Drop materialised grants first so we don't orphan them.
Expand All@@ -223,6 +248,7 @@ export class SharingRuleService implements ISharingRuleService {
}

async evaluateRule(idOrName: string, context: SharingExecutionContext): Promise<SharingRuleEvaluationResult> {
this.assertCanManageRules(context); // [ADR-0111 D6]
const rule = await this.getRule(idOrName, context);
if (!rule) throw new Error('RULE_NOT_FOUND');
if (!rule.active) {
Expand Down
Loading
Loading