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
11 changes: 11 additions & 0 deletions .changeset/25549-external-share-followup-wording.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@objectstack/plugin-sharing': patch
'@objectstack/spec': patch
---

docs: the sharing-rule pass note and the `ISharingService.grant` contract say what a share on a public object does

Clause-②: no
Metadata-protocol: none

A follow-up to the external-share change. The one-line `note` a sharing-rule pass carries when it skips recipients on a public object now also covers a record whose organization could not be resolved, which admits no recipient. The `ISharingService.grant` docblock no longer says a share on a public object enforces nothing: it is admitted for an `external` member of the record's organization.
2 changes: 1 addition & 1 deletion content/docs/kernel/runtime-services/sharing-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
- `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](/docs/permissions/sharing-rules).
- `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).
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult for the recipient (a public sharing model, unless the recipient is an `external` member of the record's organization, whose owner-only floor does consult it; 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ Three write-time refusals so a persisted share always means something:

1. **`recipientType` is narrowed to `user`** at the grant boundary; anything else → **400**. The `group` / `position` / `unit_and_subordinates` / `guest` values are not enforced by any gate today — persisting them is the ADR-0078 inert-metadata trap. The vocabulary is consolidated to one list in the contract; non-`user` principals become a documented *future direction* (delivered via rule materialisation, which already expands `team` / `business_unit` into per-user rows), not an accepted-but-ignored input on the manual surface.
2. **`grant()` validates the object is in an enforcing posture** — `effectiveSharingModel` is `private` or `read` **and** the object has an `owner_id` field. Otherwise → **422** (`SHARING_NOT_ENABLED`): a share on a public / bypass / owner-less object enforces nothing. `controlled_by_parent` is refused explicitly (Salesforce parity — a detail record is not independently shareable; its access follows the master).

*Amended 2026-10-24 (ruling 6377844010 on objectstack-ai/objectstack#25549, letter A; landed in #25591).* An external principal meets an owner-only floor on every object (ruling 6355206139, letter B), so a share row on a public object IS consulted for that population. On an object whose effective model is `public` and whose `owner_id` is real (not a federated phantom anchor), `grant()` therefore admits a `user` recipient whose `sys_member.role` in the record's organization is exactly `external`; every other recipient keeps the 422. The verdict reads the object and the recipient's population, never the granter. A sharing rule materialises rows for its external recipients only and reports the skipped half in one line. `controlled_by_parent` stays refused for every recipient.
3. **Manual and rule shares coexist by `source`.** The upsert key becomes `(object, record, recipient, source)` so a manual grant no longer clobbers a `source: 'rule'` row and vice versa. When multiple rows grant the same recipient, the widest `access_level` wins (Salesforce's RowCause model — grants are additive, never subtractive).

### D8 — Share-link re-share semantics are made explicit
Expand Down
8 changes: 5 additions & 3 deletions packages/plugins/plugin-sharing/src/sharing-rule-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,8 @@ export interface SharingRuleReconcilePassResult extends SharingRuleEvaluationRes
* [#25549] One line, present only when the pass skipped recipients: the rule
* is anchored on a public object, where a share reaches an `external` member
* of the record's organization only, so no row was written for the internal
* recipients it expands to. The count is in the pass's log line.
* recipients it expands to, nor for any recipient on a record whose
* organization could not be resolved. The count is in the pass's log line.
*/
note?: string;
}
Expand All @@ -405,8 +406,9 @@ type RuleGrantOutcome = 'granted' | 'refused' | 'internal';

/** [#25549] The pass's one-line note when it skipped internal recipients. */
function internalHalfNote(object: string): string {
return `"${object}" has a public sharing model: rows were written for external recipients only; `
+ 'the internal recipients this rule expands to already reach its records, so none was written for them';
return `"${object}" has a public sharing model: rows were written only for external members of each record's `
+ 'organization; none for the other recipients this rule expands to (internal members already reach its '
+ 'records, and a record whose organization could not be resolved admits no recipient)';
}

export interface SharingRuleServiceOptions {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,7 @@ const INERT_OBJECTS = Object.keys(INERT_SCHEMAS);
* [#25549] The classes on which a RULE's pass still aborts. On the public one
* (`whiteboard`) the grant service admits an `external` member of the record's
* organization and refuses everyone else, so a rule pass there skips its
* internal recipients and goes on — pinned below and in
* `external-recipient-public-object.test.ts`.
* internal recipients and goes on — pinned below and in block 3 of this file.
*/
const RULE_ABORTING_OBJECTS = INERT_OBJECTS.filter((o) => o !== 'whiteboard');

Expand Down Expand Up @@ -364,7 +363,7 @@ describe('[#8207] the sharing-rule evaluator can no longer materialise inert row
// a skip the pass absorbs and reports, not an abort.
const result = await rules.evaluateRule('share_whiteboard_gold', SYS);
expect(result).toMatchObject({ matchedRecords: 1, expandedUsers: 1, grantsCreated: 0, grantsRefused: 0 });
expect(result.note).toMatch(/external recipients only/);
expect(result.note).toMatch(/written only for external members of each record's organization/);
expect(engine._tables.sys_record_share ?? []).toHaveLength(0);
});

Expand Down Expand Up @@ -555,8 +554,9 @@ describe('[#25549] a public object admits a share to an external member of the r
// member, and the skip is neither a refusal nor an abort.
expect(result).toMatchObject({ matchedRecords: 2, expandedUsers: 2, grantsCreated: 2, grantsRefused: 0 });
expect(result.note).toBe(
'"board" has a public sharing model: rows were written for external recipients only; '
+ 'the internal recipients this rule expands to already reach its records, so none was written for them',
'"board" has a public sharing model: rows were written only for external members of each record\'s '
+ 'organization; none for the other recipients this rule expands to (internal members already reach its '
+ 'records, and a record whose organization could not be resolved admits no recipient)',
);
expect((engine._tables.sys_record_share ?? []).map((r) => r.recipient_id)).toEqual(['usr_ext', 'usr_ext']);
// The count reaches the log once per pass, not once per skipped grant.
Expand Down
8 changes: 5 additions & 3 deletions packages/spec/src/contracts/sharing-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,11 @@ export interface ISharingService {
* recipient (`VALIDATION_FAILED` — other recipient types are not enforced by
* any gate and are refused rather than persisted inert, ADR-0078), and an
* object in an enforcing sharing posture (`SHARING_NOT_ENABLED` — a share on
* a public / bypass / owner-less / `controlled_by_parent` object enforces
* nothing). Upserts match on `(object, record, recipient, source)` so a
* manual grant never clobbers a rule-materialised row (D7).
* a bypass / owner-less / `controlled_by_parent` object enforces nothing; on
* a public object it is admitted only for a recipient who is an `external`
* member of the record's organization, since that population's owner-only
* floor consults it). Upserts match on `(object, record, recipient, source)`
* so a manual grant never clobbers a rule-materialised row (D7).
*/
grant(input: GrantShareInput, context: ExecutionContext): Promise<RecordShare>;

Expand Down
Loading