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:nullmeans unrestricted read; otherwise returns an engine filtercanEdit/canDelete/canManageShares: boolean decisions (they returnfalserather than throwing)grant/listShares: normalizedRecordSharerows
Typical Errors
FORBIDDEN(403) — a write denied by thecanEditgate. Thrown by the sharing engine middleware;canEdititself returnsfalserather than throwing.VALIDATION_FAILED(400) —grant/revokecalled without a required field (object,recordId,recipientId, orshareId), orgrantwith a non-userrecipientType(onlyuserrows are enforced by the gates; group/position recipients are delivered via sharing rules).PERMISSION_DENIED(403) — the caller does not holdcanManageShareson 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 holdsmanage_sharingbut 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 theplatform_adminposition) are unaffected — see Rule administration.NOT_FOUND(404) — the record is missing or not visible to the caller (indistinguishable by design), or arevokeshare id does not exist / does not belong to thescoperecord.CONFLICT(409) —revokeon 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) —granton an object the sharing gates never consult (public sharing model, noowner_idfield, a bypass object,controlled_by_parent, or a federated object whoseowner_idis 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.