-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathsharing.zod.ts
More file actions
307 lines (293 loc) · 15.3 KB
/
Copy pathsharing.zod.ts
File metadata and controls
307 lines (293 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { ExpressionInputSchema } from '../shared/expression.zod';
import { strictObject } from '../shared/strict-object';
/**
* Organization-Wide Defaults (OWD)
* The baseline security posture for an object.
*/
import { lazySchema } from '../shared/lazy-schema';
import { MetadataProtectionFields } from '../kernel/metadata-protection.zod';
export const OWDModel = z.enum([
'private', // Only owner can see
'public_read', // Everyone can see, owner can edit
'public_read_write', // Everyone can see and edit
'controlled_by_parent' // Access derived from parent record (Master-Detail)
]);
/**
* Sharing Rule Type
* How is the data shared?
*
* `criteria` is the single enforced rule form (field-value predicates,
* compiled to `criteria_json` and materialised as `sys_record_share`).
* `owner`-type rules ("share records owned by members of X") were REMOVED
* from the authoring surface: they depend on live team/position membership,
* which the static materialiser cannot track (a membership change would have
* to re-materialise every dependent rule). They return as an enforced form if
* membership-reactive materialisation is designed; until then a rule that
* validates but silently does nothing is an authoring trap (ADR-0078).
*/
export const SharingRuleType = z.enum([
'criteria', // Based on field values (e.g. Status = 'Open')
]);
/**
* Sharing Level
* What access is granted?
*
* Both members map onto an enforced runtime behaviour: `read` widens the read
* filter (`buildReadFilter`), `edit` additionally opens the write gate
* (`buildWriteFilter` / `canEdit`).
*
* Removed (never enforced): `full`, documented as "Full Access (Transfer,
* Share, Delete)". NO code path granted transfer, re-share, or delete because
* of it — both enforcement sites matched `access_level in ('edit','full')`,
* making it byte-equivalent to `edit`. An admin picking it in Setup was told
* they had granted delete rights and had not; a level that validates and then
* silently does nothing is an authoring trap (ADR-0078, ADR-0049) — the same
* reason `ShareRecipientType` below dropped `queue` / `guest`.
*
* This is also where the industry model lands: record sharing widens WHICH
* ROWS a principal reaches, never WHICH VERBS they may use. Salesforce sharing
* rules stop at Read-Only / Read-Write (its Full Access is owner / hierarchy /
* Modify All only, never grantable by a rule); Dataverse AND-s any shared
* access right against the security role's own privilege. Delete and transfer
* belong to ownership, the hierarchy DEPTH scopes (ADR-0057), and admin scope
* — not to a sharing level. Re-share additionally presupposes a
* share-administration model that does not exist yet. Reviving `full` means
* designing a capability mask AND-ed with object CRUD, not re-adding an enum
* member (#3865).
*/
export const SharingLevel = z.enum([
'read', // Read Only
'edit', // Read / Write
]);
/**
* Recipient Type
* Who receives the access?
*
* Every member maps 1:1 onto a runtime recipient expansion
* (`plugin-sharing` `expandRecipient`) — this enum is the authorable subset of
* the runtime `SharingRuleRecipientType` contract:
* - `user` — a single user id.
* - `team` — every member of a `sys_team` (flat collaboration grouping;
* ADR-0090 D3 renamed the pre-D3 `group` vocabulary to `team`).
* - `position` — every holder of a position (flat; ADR-0090 D3).
* - `unit_and_subordinates` — a business unit plus every descendant unit's
* members (ADR-0057 D5 subtree widening).
* - `business_unit` — exactly one business unit's members (no subtree).
* - `field` — the RECORD-RELATIVE recipient (maintainer ruling 2026-09-02,
* #14103): `value` names a user-typed field ON THE RECORD, and each record
* the rule's criteria match is shared with the user or users that column
* names. A field with `multiple: true` shares with every user it names; an
* empty column shares with nobody (fail-closed — never a match-all
* principal). Unlike every member above, which resolves ONCE PER RULE, a
* `field` recipient expands ONCE PER MATCHED RECORD, and its grants
* re-materialise when the record's own write changes that column. That
* executor half is #15072 (`plugin-sharing`); until it lands, the
* declared-rule bootstrap skips a `field` rule with a logged warning
* (`mapRecipientType` → null) — it never seeds one as anything wider.
*
* ⛔ No `manager` member (same ruling). "Share with the owner's manager" is
* authored as a user field the application stores on the record — a snapshot
* or kept in sync, the application's explicit choice — plus a `field`
* recipient naming it. A `manager` member would walk `sys_user.manager_id`
* from the matched record, re-introducing the obligation that removed the
* `owner` recipient type: a change in the graph (someone's manager changes)
* would have to re-materialise every row that ever pointed at the old
* manager, from a `sys_user` write the record materialiser never sees. With
* `field` the recipient stays visible on the record and re-materialisation
* rides the record's own writes.
*
* Removed (never enforced): `group` (renamed → `team`) and `guest` — anonymous
* access is served by the public-form grant and share links, not sharing rules;
* a guest recipient that silently no-ops is an authoring trap (ADR-0078). The
* runtime contract additionally reserves `queue` (no `sys_queue` yet) — it is
* deliberately NOT authorable until the implementation lands.
*/
export const ShareRecipientType = z.enum([
'user',
'team',
'position',
'unit_and_subordinates',
'business_unit',
'field',
]);
/**
* Base Sharing Rule
* Common metadata for all sharing strategies.
*
* `.strict()` + the error map ride into every `.extend()`ed variant
* (zod carries the catchall and error through extension), so the
* criteria rule below inherits both.
*/
const BaseSharingRuleSchema = strictObject(
{
surface: 'this sharing rule',
// The strict error map rides `.extend()` into `CriteriaSharingRuleSchema`,
// which is the ONLY surface anything parses (`SharingRuleSchema` IS that
// extension; this base is module-private). Its two extension keys are named
// here so the suggestion pool on the extended surface is complete, and so
// the `criteria`/`filter`/`when` → `condition` aliases below point at a key
// the shape that actually runs them accepts. Before #5593 that was implicit
// in a hand-transcribed `SHARING_RULE_KEYS` array which quietly listed both
// the base's keys and the extension's; `extraKeys` is where that legitimate
// content goes now that the base's own keys come from `.shape`.
extraKeys: ['type', 'condition'],
aliases: {
// The runtime/persisted rule row spells the compiled predicate `criteria`
// (`criteria_json`); the authored key is the CEL `condition` (#3896).
criteria: 'condition',
filter: 'condition',
when: 'condition',
access: 'accessLevel',
level: 'accessLevel',
recipient: 'sharedWith',
sharewith: 'sharedWith',
sharedto: 'sharedWith',
enabled: 'active',
},
guidance: {
ownedBy:
'`ownedBy` belongs to the removed `owner`-type sharing rule — it depends on live ' +
'team/position membership, which the static materialiser cannot track, so it was ' +
'removed from the authoring surface (ADR-0078). Only `criteria` rules are ' +
'authorable; express membership-shaped access via RLS dynamic membership ' +
'(§7.3.1) or business-unit depth scopes (ADR-0057).',
},
history:
'Until this shape was closed, these were dropped silently — the rule still parsed, so a share the ' +
'author intended was never materialised (or a constraint never applied).',
},
{
// Identification
name: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique rule name (snake_case)'),
label: z.string().optional().describe('Human-readable label'),
description: z.string().optional().describe('Administrative notes'),
// Scope
object: z.string().describe('Target Object Name'),
active: z.boolean().default(true),
// Access
accessLevel: SharingLevel.default('read'),
// Recipient (Whom to share with)
sharedWith: strictObject(
{
surface: 'this sharing-rule recipient',
aliases: { id: 'value', target: 'value' },
history:
'Until this shape was closed, these were dropped silently — the recipient still parsed, so the ' +
'grant could land on the wrong principal without a diagnostic.',
},
{
type: ShareRecipientType,
value: z.string().describe(
'The recipient principal: the id or code of the user / team / position / business unit — or, for ' +
"`type: 'field'`, the snake_case name of a user-typed field on the record whose value names the " +
'user or users to share each matched record with',
),
}).superRefine((recipient, ctx) => {
// [#14103] A `field` recipient's `value` is a FIELD NAME, not a principal id,
// so it has a grammar the other members' opaque ids do not: the machine-name
// shape every `FieldSchema.name` carries. Refusing here, at authoring, keeps
// two traps out of the executor: an empty name (a rule naming no column would
// be skipped at seed — the silent under-share this file's history keeps
// closing, ADR-0078) and a dotted path (`owner.manager_id` is a graph walk
// spelled as a value — exactly what the ruling's ⛔ on a `manager` member
// refuses). Scoped to `field`, so nothing that parsed before parses
// differently: the other members' `value` stays the opaque string it was.
if (recipient.type !== 'field') return;
if (!/^[a-z_][a-z0-9_]*$/.test(recipient.value)) {
ctx.addIssue({
code: 'custom',
path: ['value'],
message:
`\`sharedWith.value\` must name a user-typed field on the record when \`type\` is 'field' ` +
`(got ${JSON.stringify(recipient.value)}). ` +
'A field name is snake_case (`assignees`, `manager_user`) — not a principal id and not a dotted ' +
'path: the recipient is read from that one column on each matched record, never walked from it. ' +
"To share with the owner's manager, store the manager in a user field on the record and name that field.",
});
}
}).describe(
'The recipient of the shared access: a principal resolved once per rule, or — `type: field` — the ' +
'user or users named by a field on each matched record',
),
// ADR-0010 — runtime protection envelope (internal — set by loader).
//
// [#6245] Declared for the reason `webhook.zod.ts` states for its own spread:
// BOTH metadata load paths call `applyProtection` on EVERY type, so a
// package-loaded sharing rule already carries these keys by the time anything
// re-parses it. This shape is `.strict()`, so until now that stamped envelope
// was not merely dropped — it was REJECTED, which stayed invisible only
// because the type resolved no schema at the overlay door and nothing parsed
// it there. #6245 binds that door, so declaring the envelope is what keeps
// the new 422 aimed at malformed AUTHOR input instead of at the runtime's own
// stamp. `metadata-type-schemas.test.ts` names this failure exactly ("is
// strict and does not declare the ADR-0010 envelope, so `applyProtection`
// output fails to parse — a hard 422 on the overlay path") and prescribes
// this spread as the fix.
...MetadataProtectionFields,
});
/**
* 1. Criteria-Based Sharing Rule
* Share records that meet specific field criteria.
*/
export const CriteriaSharingRuleSchema = lazySchema(() => BaseSharingRuleSchema.extend({
type: z.literal('criteria'),
condition: ExpressionInputSchema.describe('Predicate (CEL). e.g. P`record.department == "Sales"`'),
}));
/**
* Master Sharing Rule Schema
*
* ADR-0058 D3 — closes #1887. The CEL `condition` of a criteria-based rule is
* COMPILED to the runtime `criteria_json` FilterCondition by the canonical
* `@objectstack/formula` compiler at seed / `defineRule` time, and ENFORCED:
* records matching the criteria materialise `sys_record_share` grants for the
* resolved recipients. Supported recipients: `user` / `team` / `position` /
* `unit_and_subordinates` / `business_unit` (ADR-0057 D5; ADR-0090 D3) — every
* authorable recipient expands at runtime (`plugin-sharing` `expandRecipient`)
* — plus `field`, the record-relative recipient (#14103): resolved per matched
* record from a user-typed column on it; its executor is the services half,
* #15072, and until that lands a `field` rule is skipped LOUDLY at seed.
*
* The whole authorable surface is enforced — nothing here validates and then
* silently does nothing (ADR-0078). Removed to keep it that way: `owner`-type
* rules (`ownedBy` — live-membership-dependent, needs membership-reactive
* re-materialisation that is not designed yet) and the `group` / `guest`
* recipients (`group` renamed → `team`; anonymous access is the public-form
* grant / share-link surface). A `condition` the compiler cannot lower
* (functions, cross-object traversal) is skipped and logged — never seeded as
* a permissive match-all (ADR-0049).
*
* That last sentence is a claim about the RUNTIME, not merely about this
* schema, and #3896 found the two entries that were not holding it up: the
* REST `POST {basePath}/sharing/rules` → `SharingRuleService.defineRule` path
* (which plucks a body rather than parsing it here, so a missing or misspelled
* `criteria` became `criteria_json: null` → the empty filter → every record of
* the object), and a direct `sys_sharing_rule` insert, which is what authoring
* a rule in Setup issues. Both now reject a match-all criteria, and the
* evaluator treats one as matching NOTHING — see `plugin-sharing`'s
* `rule-criteria.ts`. Anything added to this surface owes the same check: an
* authoring shape is only as safe as the least-validating path that writes it.
*
* Kept as the `SharingRuleType`-discriminated form so a future enforced rule
* type (e.g. membership-reactive owner-based) re-joins as a union member.
*/
export const SharingRuleSchema = CriteriaSharingRuleSchema;
export type SharingRule = z.input<typeof SharingRuleSchema>;
/** Post-parse shape of {@link SharingRule} — defaults applied, transforms run (ADR-0122). */
export type SharingRuleParsed = z.infer<typeof SharingRuleSchema>;
export type CriteriaSharingRule = z.input<typeof CriteriaSharingRuleSchema>;
/** Post-parse shape of {@link CriteriaSharingRule} — defaults applied, transforms run (ADR-0122). */
export type CriteriaSharingRuleParsed = z.infer<typeof CriteriaSharingRuleSchema>;
export type OWDModel = z.input<typeof OWDModel>;
export type ShareRecipientType = z.input<typeof ShareRecipientType>;
export type SharingLevel = z.input<typeof SharingLevel>;
export type SharingRuleType = z.input<typeof SharingRuleType>;
/**
* Type-safe factory for a record sharing rule. Validates at authoring time via
* `.parse()` and accepts input-shape config (optional defaults, CEL
* shorthand) — preferred over a bare `: SharingRule` literal.
*/
export function defineSharingRule(config: z.input<typeof SharingRuleSchema>): SharingRuleParsed {
return SharingRuleSchema.parse(config);
}