ObjectStackObjectStack

services.sharing

Record-level sharing and editability checks.

services.sharing

  • Stability: stable
  • Canonical source: packages/spec/src/contracts/sharing-service.ts

Methods

services.sharing.buildReadFilter(object: string, context: ExecutionContext): Promise<unknown | null>
services.sharing.canEdit(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
services.sharing.canDelete(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
services.sharing.canManageShares(object: string, recordId: string, context: ExecutionContext): Promise<boolean>
services.sharing.grant(input: GrantShareInput, context: ExecutionContext): Promise<RecordShare>
services.sharing.revoke(shareId: string, context: ExecutionContext, scope?: { object: string; recordId: string }): Promise<void>
services.sharing.listShares(object: string, recordId: string, context: ExecutionContext): Promise<RecordShare[]>

Every method above adjudicates access, so each takes the complete resolveAuthzContext envelope (ExecutionContext) — not a per-site subset (#6523, applying the #6206 ruling). The six-field SharingExecutionContext these signatures used to name was retired in #7218; pass the whole context you were handed, unchanged.

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, a hierarchy manager whose effective write DEPTH (unit / unit_and_below / own_and_reports) covers the record's owner (via the enterprise hierarchy-scope-resolver), or system context. A deployment without @objectstack/plugin-security fails closed to owner-only; without the enterprise resolver the DEPTH path is inert and authority stays owner + Modify-All. Pass { isSystem: true } only from platform-internal machinery.

The verb boundary (ADR-0111 D3)

A share widens which rows a principal reaches, never which verbs they may use. canEdit (the update gate) accepts an edit-level share; canDelete (the delete gate) does not — delete is ownership (widened by write DEPTH) or the modifyAllRecords super-user bypass only. Delete is not a share level and never will be; a future per-record delete grant would be a capability mask AND-ed with object CRUD, not a fourth access_level.

Returns

  • buildReadFilter: null means unrestricted read; otherwise returns an engine filter
  • canEdit / canDelete / 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 (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). On the sharing-rule surface (ISharingRuleService, declared in the same canonical source — listRules / getRule / defineRule / deleteRule / evaluateRule) the same code carries a second condition: the caller holds manage_sharing but their session resolves no active organization, and an org-scoped capability with no organization has no tenant whose rules it authorizes. System contexts and platform operators (manage_platform_settings, or the platform_admin position) are unaffected — see Rule administration.
  • 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, controlled_by_parent, or a federated object whose owner_id is the platform's injected anchor rather than a real remote column — the platform provisions no storage for a federated object, so the gates read that column off a table that has not got it and can never admit).

Enforcement is automatic — do not re-check it in a hook

With @objectstack/plugin-sharing installed, the gates run inside the engine: its middleware picks the gate by verb — canEdit before a by-id update, canDelete before a delete — and throws FORBIDDEN before the hook chain could ask anything. A hook that re-checks adds nothing, and it cannot ask this service at all: a hook context is built key by key by the engine (object / event / input / session / provenance / user / api / transaction / ql) and carries no services key, so ctx.services?.sharing?.canEdit(…) is undefined there and if (!ok) throw … rejects every write (#5720). A hook's own channel is ctx.api — use it for business rules (examples).

Example

Call canEdit only from code that holds the service — a plugin that resolved it from the kernel service registry, or a managed runtime's services.sharing binding — for example to pre-flight an affordance before offering it:

import type { ISharingService } from '@objectstack/spec/contracts';

export async function mayEditContract(
  sharing: ISharingService,
  recordId: string,
  session: { userId?: string; organizationId?: string; positions?: string[] },
): Promise<boolean> {
  return sharing.canEdit('contract', recordId, {
    userId: session.userId,
    // The execution context names the org `tenantId`; a session exposes the
    // same value as `organizationId` (the `session.tenantId` alias was removed in
    // v11, #3290).
    tenantId: session.organizationId,
    positions: session.positions,
  });
}

canEdit returns false rather than throwing, so a caller decides what a denial means — hiding a button, or raising its own error.

On this page