Skip to content

Commit dc804fb

Browse files
committed
fix(plugin-sharing): name the federated-anchor remedy before the model, and reconcile the sharing-rules page with D7's real owner_id
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyFB75QwwCDu3S5GNZ
1 parent 6a24dc2 commit dc804fb

3 files changed

Lines changed: 39 additions & 10 deletions

File tree

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -137,17 +137,18 @@ the rows it owns and the rows granted to it:
137137
the object (for example `supplier in current_user.<key>`). Such a policy
138138
lifts the owner-only floor for that object, and the policy bounds the rows.
139139

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

146-
A sharing rule on an object that no sharing gate consults for any recipient (a
147-
system object with no `owner_id`, such as `sys_session`; a public object with no
148-
owner; a `controlled_by_parent` detail; a bypass object) is refused when it is
149-
saved, with `SHARING_NOT_ENABLED` (422) and a sentence that names the object and
150-
how to turn record sharing on for it. This holds for Setup's form and the
146+
A sharing rule on an object that no sharing gate consults for any recipient is
147+
refused when it is saved: an object with no real `owner_id` (a system object
148+
without one, such as `sys_session`, or a federated object whose `owner_id` is
149+
the platform's injected anchor), a `controlled_by_parent` detail, or a bypass
150+
object. It is refused with `SHARING_NOT_ENABLED` (422) and a sentence that
151+
names the object and how to turn record sharing on for it. This holds for Setup's form and the
151152
`/data` door as well as `POST /api/v1/sharing/rules`, and for an edit that moves
152153
a rule onto such an object. A rule saved before this check keeps its row and
153154
can still be switched off.

‎packages/plugins/plugin-sharing/src/sharing-rule-object-enforcement.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const ORG = 'org_30621';
4242
const ENFORCED = 'os30621_deal';
4343
const OWNERLESS = 'os30621_session';
4444
const PUBLIC_OWNED = 'os30621_notice';
45+
const PUBLIC_FEDERATED = 'os30621_ext_customer';
4546

4647
const SYSTEM: ExecutionContext = { isSystem: true, positions: [], permissions: [] };
4748
const ADMIN = {
@@ -88,6 +89,12 @@ async function boot() {
8889
name: PUBLIC_OWNED, label: 'Notice', sharingModel: 'public_read_write',
8990
fields: { ...recordFields, owner_id: text('owner_id') },
9091
} as never);
92+
// A PUBLIC federated object (ADR-0015): the registry injects `owner_id` as an
93+
// anchor the remote table does not have. Registered only, never written to.
94+
ql.registerObject({
95+
name: PUBLIC_FEDERATED, label: 'External customer', sharingModel: 'public_read_write',
96+
external: { remoteName: 'customers' }, fields: { id: { ...text('id'), primary: true }, name: text('name') },
97+
} as never);
9198
ql.registerObject({
9299
name: 'sys_sharing_rule', label: 'Sharing Rule', isSystem: true,
93100
fields: {
@@ -102,7 +109,7 @@ async function boot() {
102109
const sharing = new SharingService({ engine: ql as never });
103110
const rules = new SharingRuleService({ engine: ql as never, sharing });
104111
bindRuleObjectEnforcementGuard(ql as never, sharing);
105-
return { ql, rules };
112+
return { ql, rules, sharing };
106113
}
107114

108115
const rowFor = (object: string, name: string) => ({
@@ -169,6 +176,24 @@ describe('[#30621] the /data door refuses a sharing rule on an object without re
169176
});
170177
});
171178

179+
describe('[#30621] the refusal sentence names the remedy for the arm that refused', () => {
180+
it('a PUBLIC federated object with the injected owner anchor is told to declare a real remote owner_id', async () => {
181+
const { ql, sharing } = await boot();
182+
expect((ql as any).getSchema(PUBLIC_FEDERATED)?.fields?.owner_id, 'the registry injected the anchor').toBeTruthy();
183+
const refusal = sharing.sharingRuleObjectRefusal(PUBLIC_FEDERATED);
184+
expect(refusal).toContain(`'${PUBLIC_FEDERATED}'`);
185+
expect(refusal).toMatch(/declare a real remote 'owner_id' column/);
186+
// Not the generic remedy: the object already has an owner_id, and a
187+
// private model would still meet the phantom-anchor refusal.
188+
expect(refusal).not.toMatch(/sharingModel: 'private'/);
189+
const err = await ql.insert('sys_sharing_rule', rowFor(PUBLIC_FEDERATED, 'ext_rule'), { context: ADMIN } as never)
190+
.catch((x: unknown) => x as any);
191+
expect(err?.code).toBe('SHARING_NOT_ENABLED');
192+
expect(err?.status).toBe(422);
193+
expect(await storedRules(ql)).toHaveLength(0);
194+
});
195+
});
196+
172197
describe('[#30621] defineRule (POST /api/v1/sharing/rules) refuses the same rule', () => {
173198
const input = (object: string, name: string) => ({
174199
name, label: name, object, recipientType: 'position', recipientId: 'sales_rep', criteria: { stage: 'won' },

‎packages/plugins/plugin-sharing/src/sharing-service.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1807,7 +1807,10 @@ export class SharingService implements ISharingService {
18071807
if (declared === 'controlled_by_parent') {
18081808
return 'Author the rule on its master object instead.';
18091809
}
1810-
if (schema && effectiveSharingModel(schema) !== 'public' && hasOwnerField(schema) && hasPhantomOwnerAnchor(schema)) {
1810+
// Asked before the model: a federated object whose `owner_id` is the
1811+
// injected anchor already HAS that field, public or not, and a private
1812+
// model would still meet the phantom-anchor refusal.
1813+
if (schema && hasOwnerField(schema) && hasPhantomOwnerAnchor(schema)) {
18111814
return `To use sharing rules on it, declare a real remote '${OWNER_FIELD}' column on the federated object.`;
18121815
}
18131816
return `To use sharing rules on it, give the object a private or read-only sharing model `

0 commit comments

Comments
 (0)