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
15 changes: 15 additions & 0 deletions .changeset/25549-external-share-public-object.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/plugin-sharing': minor
'@objectstack/lint': minor
---

feat: a share or sharing rule on a public object reaches an external member of the record's organization

Clause-②: yes (widening: the grant door and the materialiser admit one recipient population on effective-public objects)
Metadata-protocol: semantic

An external member (`sys_member.role` exactly `external`) reads only the rows it owns and the rows shared to it, on every object. On a `public_read_write` object, or a system object with no `sharingModel`, a share to it used to be refused as one that would never take effect. For that member it is the only thing that does.

- **Manual shares.** `SharingService.grant` now admits a `user` recipient on such an object when the recipient is an `external` member of the record's organization. Every other recipient keeps the 422 `SHARING_NOT_ENABLED`, with a message that says the share is consulted for external members only. A user who is external in one organization and a member of another is judged in the record's organization. `controlled_by_parent`, owner-less, federated phantom-anchor and bypass objects are refused for every recipient, as before.
- **Sharing rules.** A rule's pass writes rows for the external members it expands to and skips the internal ones without failing the pass. When it skips any, the evaluate result carries a one-line `note`, and the pass logs the count once.
- **Lint.** `sharing-rule-object-not-shareable` on a public object is now a warning: the rule is effective for external recipients only. `os explain` says when to keep it and when to change the object's `sharingModel`.
5 changes: 3 additions & 2 deletions content/docs/permissions/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,9 @@ export const KeyAccountQualifiedContactRule = defineSharingRule({
Note the object it is anchored on. `showcase_contact` is OWD `private`, which is
what makes this grant one a read gate actually consults — sharing only ever
WIDENS a baseline, so a rule on an object that is already `public_read_write`
grants nothing and is refused at boot. That is an authoring-time build error
(`sharing-rule-object-not-shareable`), not a runtime surprise.
grants nothing to staff, and reaches only the external (portal) members it names.
The authoring-time advisory `sharing-rule-object-not-shareable` says so before
anything boots.

Because AI agents act through the same permission-aware surface, these rules bound
agent access exactly as they bound users ([Actions as Tools](/docs/ai/actions-as-tools)).
Expand Down
6 changes: 6 additions & 0 deletions content/docs/permissions/sharing-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ the rows it owns and the rows granted to it:
the object (for example `supplier in current_user.<key>`). Such a policy
lifts the owner-only floor for that object, and the policy bounds the rows.

On a `public_read_write` object, or a system object with no `sharingModel`, a
share or sharing rule takes effect for external recipients only: the grant is
accepted for a recipient who is an `external` member of the record's
organization, every other recipient is refused with `SHARING_NOT_ENABLED`
(422), and a rule's evaluation writes rows for its external recipients alone.

`externalSharingModel`, the external dial ADR-0090 D11 declared, was removed
in `@objectstack/spec` 18 (ADR-0049 enforce-or-remove): while it existed no
request resolved an external principal, so the value changed no answer. The schema
Expand Down
9 changes: 5 additions & 4 deletions examples/app-crm/objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,11 @@ export default defineStack({
// were removed under ADR-0049 enforce-or-remove (#9698), the same call
// #9237 made for app-showcase's two.
//
// ⛔ Do not re-add one on a public object — `sharing-rule-object-not-shareable`
// now fails the build, and its message states the two honest fixes. Giving
// this app a LIVE sharing demonstration means giving it a `private` object
// first; that is an access-matrix change (ADR-0090 D6 review), not a rider.
// ⛔ Do not re-add one for staff on a public object — such a rule reaches
// external (portal) members only, and `sharing-rule-object-not-shareable`
// says so. Giving this app a LIVE sharing demonstration for staff means
// giving it a `private` object first; that is an access-matrix change
// (ADR-0090 D6 review), not a rider.

// Seed data
data: CrmSeedData,
Expand Down
11 changes: 7 additions & 4 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2222,12 +2222,15 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
// does not lower means the rule is SKIPPED by enforcement.
// - `object` decides whether the grant is refused outright: reconcile hands
// each row to `SharingService.grant`, whose ADR-0111 D7 pre-flight THROWS
// `SHARING_NOT_ENABLED` when the anchor's effective sharing model is
// `public` or it is a `controlled_by_parent` detail. Both are decidable
// from authored metadata; the other arms of that verdict (`owner_id`, the
// `SHARING_NOT_ENABLED` when the anchor is a `controlled_by_parent`
// detail, and, when its effective sharing model is `public`, for every
// recipient who is not an `external` member of the record's organization
// (#25549: that arm is advice, a `warning`). Both are decidable from
// authored metadata; the other arms of that verdict (`owner_id`, the
// bypass set, federated anchors) are not, and are excluded by name.
//
// Either way the grant is declared and does not exist. The lint calls the
// Either way the grant is declared and does not exist (for staff, on the
// public arm). The lint calls the
// same compiler and mirrors the same verdict function, from the same inputs
// — the verdict cannot drift from the consumers'. Gating for the ADR-0078
// reason `SharingRuleSchema`'s own docblock states: the whole authorable
Expand Down
41 changes: 25 additions & 16 deletions packages/lint/src/rule-explanations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1670,22 +1670,31 @@ const SHARING_RULE_RUNTIME_VARIABLE_CONDITION_EXPLANATION: RuleExplanation = {

const SHARING_RULE_OBJECT_NOT_SHAREABLE_EXPLANATION: RuleExplanation = {
rule: 'sharing-rule-object-not-shareable',
covers: 'why a rule on a public object grants nothing',
paragraphs: [
'Sharing only ever WIDENS an organization-wide default (OWD) baseline, and on the widest baseline there is ' +
'nothing left to widen. An object\'s effective sharing model is `public` when it declares ' +
'`sharingModel: \'public_read_write\'`, or when it declares none and is a system object (`isSystem: true` ' +
'or a `sys_` name), which ADR-0090 D1 resolves to public. A custom object with no `sharingModel` resolves ' +
'to private and is not reported.',
SHARING_INERT_GRANT,
'Here the refusal reads "\'OBJECT\' is not under record-sharing enforcement". Measured: `buildReadFilter` ' +
'returns `null` for such an object — NO record-level filter at all — so every principal already reads ' +
'every row, and the rule advertises a restriction that does not exist.',
'To fix it, decide which half is wrong. If the ACCESS is right — everyone should read and write these ' +
'records — the rule is dead metadata: delete it (ADR-0049 enforce-or-remove). If the RULE is right — ' +
'only the named audience should reach these records — the object\'s OWD is the defect: set ' +
'`sharingModel: \'private\'` (the owner plus explicit shares) or `\'public_read\'`, and the rule starts ' +
'enforcing. Do not re-home the rule onto another public object; that moves the inertness instead of ' +
covers: 'why a rule on a public object reaches externals only',
paragraphs: [
'Sharing only ever WIDENS an organization-wide default (OWD) baseline, and for the people who work in the ' +
'organization a public baseline has nothing left to widen. An object\'s effective sharing model is ' +
'`public` when it declares `sharingModel: \'public_read_write\'`, or when it declares none and is a ' +
'system object (`isSystem: true` or a `sys_` name), which ADR-0090 D1 resolves to public. A custom object ' +
'with no `sharingModel` resolves to private and is not reported.',
'An EXTERNAL member of the organization (`sys_member.role` exactly `external`: a supplier\'s or a ' +
'customer\'s portal user) is different. Its baseline on every object is the rows it owns, so a share is ' +
'how it reaches a record of a public object. `SharingService.grant` therefore admits a grant on such an ' +
'object for a recipient who is an external member of the record\'s organization, and its ' +
'`assertNotInertGrant` pre-flight (ADR-0111 D7) refuses everyone else with SHARING_NOT_ENABLED: "\'OBJECT\' ' +
'has a public sharing model, so a share row on it is consulted only for an external member". The rule\'s ' +
'pass writes rows for the external recipients it expands to, skips the internal ones, and says so in one ' +
'line (`note` on the evaluate result, and one log line per pass).',
'Measured for an internal reader: `buildReadFilter` returns `null` on such an object — NO record-level ' +
'filter at all — so every member already reads every row, and for them the rule advertises a restriction ' +
'that does not exist. That is why this is advice and not an error: it is right when the rule\'s audience ' +
'is external, and only half of what it says when the audience includes staff.',
'To act on it, decide which half is wrong. If the audience is external — the rule hands a supplier the ' +
'records it should see — keep it. If the ACCESS is right for staff and the rule names staff, the staff ' +
'half is dead metadata: narrow the recipients (ADR-0049 enforce-or-remove). If only the named audience ' +
'should reach these records, staff included, the object\'s OWD is the defect: set ' +
'`sharingModel: \'private\'` (the owner plus explicit shares) or `\'public_read\'`, and the rule enforces ' +
'for everyone. Do not re-home the rule onto another public object; that moves the inertness instead of ' +
'removing it.',
],
};
Expand Down
16 changes: 9 additions & 7 deletions packages/lint/src/validate-sharing-rule-enforceability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,11 +319,13 @@ const anchoredOn = (owd: unknown, extra: Record<string, unknown> = {}) => ({
});

describe('validateSharingRuleEnforceability — an anchor no gate would consult goes RED', () => {
it('flags `public_read_write` — nothing left to widen', () => {
it('advises on `public_read_write` — effective for external recipients only (#25549)', () => {
const findings = validateSharingRuleEnforceability(anchoredOn('public_read_write'));
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
severity: 'error',
// Advice, not a refusal: the runtime admits the grant for a recipient who
// is an `external` member of the record's organization.
severity: 'warning',
rule: SHARING_RULE_OBJECT_NOT_SHAREABLE,
// The defect is on `object`, not on `condition` — a different field with
// a different fix, so it gets its own path.
Expand All @@ -333,12 +335,12 @@ describe('validateSharingRuleEnforceability — an anchor no gate would consult
// An author must be able to act on this: it has to name the object, the
// rule (the `where` above), and WHY the rule cannot take effect.
expect(findings[0].message).toBe(
'anchor object "crm_opportunity" declares sharingModel \'public_read_write\', so the grant is refused ' +
'(SHARING_NOT_ENABLED) and the rule restricts nothing',
'anchor object "crm_opportunity" declares sharingModel \'public_read_write\', so the rule is effective for ' +
'external recipients only',
);
expect(explanationOf(SHARING_RULE_OBJECT_NOT_SHAREABLE)).toMatch(/`buildReadFilter` returns `null`/);
// …and both honest fixes, because which one is right is the author's call.
expect(findings[0].hint).toMatch(/delete it/);
// …and both honest choices, because which one is right is the author's call.
expect(findings[0].hint).toMatch(/Keep it if its audience is external/);
expect(findings[0].hint).toMatch(/sharingModel: 'private'/);
});

Expand Down Expand Up @@ -654,7 +656,7 @@ describe('[#22161] one-line verdicts — the four ids', () => {
const MOVED: Record<string, readonly string[]> = {
[SHARING_RULE_UNLOWERABLE_CONDITION]: ['`createDeclaredSharingRuleSource`', 'one WARN line in the log', 'ADR-0049', 'SharingRuleService', 'INVALID_FILTER'],
[SHARING_RULE_RUNTIME_VARIABLE_CONDITION]: ['MATERIALISED', 'no "current user"', 'the rule is SKIPPED'],
[SHARING_RULE_OBJECT_NOT_SHAREABLE]: ['WIDENS', 'ADR-0090 D1', '`assertNotInertGrant`', 'not under record-sharing enforcement', 'restriction that does not exist'],
[SHARING_RULE_OBJECT_NOT_SHAREABLE]: ['WIDENS', 'ADR-0090 D1', '`assertNotInertGrant`', 'consulted only for an external member', 'restriction that does not exist'],
[SHARING_RULE_OBJECT_CONTROLLED_BY_PARENT]: ['ADR-0055', 'no shares to widen', '`assertNotInertGrant`', 'whatever the MASTER grants them'],
};

Expand Down
35 changes: 25 additions & 10 deletions packages/lint/src/validate-sharing-rule-enforceability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@
* a warning. It fails closed (the recipient under-sees rather than over-sees),
* which is why it was survivable, not why it is acceptable.
*
* [#25549] One arm is no longer an `error`: on a `public` anchor the runtime
* admits the grant for a recipient who is an `external` member of the record's
* organization (the external floor consults a share row there) and refuses the
* rest, so that rule IS live for its external half. Whether a rule's audience
* is external is runtime data, not metadata, so the arm reports a `warning`
* saying what the rule does — "effective for external recipients only".
*
* Measured before shipping: every sharing-rule condition and RLS predicate
* declared anywhere in this repo (examples, platform permission sets) lowers
* cleanly, so the gate turns nothing red that works today.
Expand Down Expand Up @@ -439,10 +446,13 @@ function masterOf(obj: AnyRec): string | undefined {
* `buildReadFilter`, all three postures), the arms differ in the direction
* that decides the wording:
*
* - **`public` OWD** → grant refused, `buildReadFilter` returns `null`, zero
* share rows. Nothing is filtered, so the intended audience already reads
* every row — and so does everyone else. Nobody UNDER-sees; the harm is
* that the declaration advertises a restriction that does not exist.
* - **`public` OWD** → grant refused for an internal recipient,
* `buildReadFilter` returns `null` for an internal reader, zero share rows
* for staff. Nothing is filtered for them, so the intended staff audience
* already reads every row — and so does everyone else. Nobody UNDER-sees;
* the harm is that the declaration advertises a restriction that does not
* exist. An `external` recipient is admitted and its row is consulted
* (#25549), which is why this arm is a `warning`.
* - **`controlled_by_parent`** → grant refused with a DIFFERENT reason
* ("share the master record instead"), and the detail's visibility comes
* from its master's path (ADR-0055), not from this rule. Here the author
Expand Down Expand Up @@ -524,21 +534,26 @@ function anchorFindings(
? `declares sharingModel 'public_read_write'`
: 'declares no sharingModel and is a system object, which resolves to public';

// [#25549] Advice, not a refusal: the runtime admits this rule's grants for
// the recipients who are `external` members of the record's organization
// (their owner_only floor consults a share row on a public object) and skips
// the rest. Whether the rule's audience is external is runtime data
// (`sys_member.role`), not metadata, so this door can say what the rule does
// and cannot say it is wrong.
return [{
severity: 'error',
severity: 'warning',
rule: SHARING_RULE_OBJECT_NOT_SHAREABLE,
where,
path,
// [#22161] One verdict sentence; why a public baseline has nothing to
// widen, and the measured read filter, is the `os explain` entry's.
// widen for staff, and the measured read filter, is the `os explain` entry's.
message:
`anchor object "${object}" ${declared}, so the grant is refused (SHARING_NOT_ENABLED) and the rule ` +
'restricts nothing',
// [#22161] One remedy line naming both honest fixes; how to tell which
`anchor object "${object}" ${declared}, so the rule is effective for external recipients only`,
// [#22161] One remedy line naming the honest choices; how to tell which
// half is wrong, and why re-homing the rule fixes nothing, are the
// `os explain` entry's.
hint:
`If everyone should reach these records, delete it; if only its audience should, set "${object}"'s ` +
`Keep it if its audience is external; if staff should be narrowed too, set "${object}"'s ` +
`sharingModel: 'private' or 'public_read'`,
}];
}
Expand Down
Loading
Loading