Repository navigation
Expand file tree
/
Copy pathsys-sharing-rule.object.ts
More file actions
323 lines (304 loc) · 14.7 KB
/
Copy pathsys-sharing-rule.object.ts
File metadata and controls
323 lines (304 loc) · 14.7 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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { ObjectSchema, Field } from '@objectstack/spec/data';
/**
* sys_sharing_rule — Declarative record-sharing rule.
*
* Salesforce-style criteria-based sharing: "any record on object O that
* matches criteria C is granted access level A to recipient R". Rules
* are evaluated by `@objectstack/plugin-sharing` and materialise their
* grants as rows in `sys_record_share` with `source='rule'` and
* `source_id={rule.id}` so the evaluator can reconcile (delete + re-
* insert) on rule updates without touching manual grants.
*
* Evaluation triggers:
* - `afterInsert` / `afterUpdate` on the target object (per-record,
* incremental — the hot path).
* - REST `POST /sharing/rules/:id/evaluate` (admin-initiated
* bulk reconcile — used after rule edits).
*
* Criteria are stored as JSON (a normal `FilterCondition`) so the
* existing engine `find()` can do the matching natively. v1 supports
* simple `{field, op, value}` style filters; CEL predicates are a
* follow-up.
*
* @namespace sys
*/
export const SysSharingRule = ObjectSchema.create({
name: 'sys_sharing_rule',
label: 'Sharing Rule',
pluralLabel: 'Sharing Rules',
icon: 'shield-check',
isSystem: true,
managedBy: 'config',
// Sharing rules can now be authored visually via the Studio criteria
// builder (apps/studio/src/components/SharingCriteriaBuilder.tsx).
// We still recommend `defineSharingRule({...})` for repo-controlled
// baselines, but admins can safely create/edit/delete from the UI.
userActions: { create: true, edit: true, delete: true, import: false },
description: 'Grants a group of people access to the records that match a condition.',
displayNameField: 'name',
nameField: 'name', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
highlightFields: ['name', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active'],
listViews: {
active: {
type: 'grid',
name: 'active',
label: 'Active',
data: { provider: 'object', object: 'sys_sharing_rule' },
columns: ['label', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'updated_at'],
filter: [{ field: 'active', operator: 'equals', value: true }],
sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
inactive: {
type: 'grid',
name: 'inactive',
label: 'Inactive',
data: { provider: 'object', object: 'sys_sharing_rule' },
columns: ['label', 'object_name', 'recipient_type', 'recipient_id', 'updated_at'],
filter: [{ field: 'active', operator: 'equals', value: false }],
sort: [{ field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
by_object: {
type: 'grid',
name: 'by_object',
label: 'By Object',
data: { provider: 'object', object: 'sys_sharing_rule' },
columns: ['object_name', 'label', 'recipient_type', 'access_level', 'active'],
// [#24701] Ordered by the target object, not grouped on it: a group header
// draws `object_name` as its stored API name, while the Object cell draws
// the object's icon and label (its object-picker widget). No column stores
// the label, so no grouping key could carry it. The sort keeps one
// object's rules together (`sys-sharing-rule.by-object.test.ts`).
sort: [{ field: 'object_name', order: 'asc' }, { field: 'label', order: 'asc' }],
pagination: { pageSize: 100 },
},
all_rules: {
type: 'grid',
name: 'all_rules',
label: 'All',
data: { provider: 'object', object: 'sys_sharing_rule' },
columns: ['label', 'object_name', 'recipient_type', 'recipient_id', 'access_level', 'active', 'updated_at'],
sort: [{ field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
},
fields: {
id: Field.text({ label: 'Rule ID', required: true, readonly: true, group: 'System' }),
// A stored rule always carries its organization. A rule that reaches every
// organization is a DECLARED rule, marked by the typed `deploymentWide`
// projection (sharing-rule-service.ts) — never by an empty column here.
organization_id: Field.lookup('sys_organization', {
label: 'Organization',
required: false,
group: 'System',
description: 'The organization this rule belongs to.',
}),
name: Field.text({
label: 'Name',
required: true,
maxLength: 100,
// Field `description`s on this object are ADMIN-FACING help text rendered
// under each input in Setup — not notes for the next engineer. They must
// not name tables, columns, enum values, ADRs or third-party libraries:
// the reader is a tenant admin who sees only the labels in the dropdown
// (objectstack#3821). Implementation detail belongs in the doc comments
// above, where it already is.
description: 'Identifies the rule. Lowercase letters, digits and underscores only.',
group: 'Identity',
}),
label: Field.text({
label: 'Display Label',
required: true,
maxLength: 200,
group: 'Identity',
}),
description: Field.textarea({
label: 'Description',
required: false,
group: 'Identity',
}),
object_name: Field.text({
label: 'Object',
required: true,
maxLength: 100,
// Rendered as the sharing-rule object picker: only the objects a rule
// can take effect on (`GET /api/v1/sharing/shareable-objects`, the save
// doors' own verdict), business objects first. It is the `object-ref`
// picker when that door does not answer the list, and a text input when
// the `field:shareable-object-ref` widget is unavailable.
widget: 'shareable-object-ref',
description: 'The object whose records this rule shares.',
group: 'Target',
}),
criteria_json: Field.textarea({
label: 'Criteria',
// [#3896 → ADR-0113] Mandatory in substance AND now in metadata. Under
// the old tri-binding this was un-declarable — `required: true` implied
// a NOT NULL migration that every deployed tenant's legacy null rows
// block. ADR-0113 split the knob: this is the WRITE contract only (an
// insert must provide it, an update may not null it out, rows predating
// the gate rest — an admin can still `active: false` an over-broad
// legacy rule). Deliberately NO `storage.notNull`: those legacy nulls
// are exactly the case the split exists for. The declaration is the
// first line; `bindRuleCriteriaGuard` still rejects the NON-null
// match-all shapes `required` cannot express, and `defineRule` guards
// the API path with the shape-naming message.
required: true,
// Rendered as a visual criteria builder scoped to the selected object's
// fields (dependsOn: object_name), storing the same JSON FilterCondition.
// An "Edit as JSON" fallback keeps hand-authored / advanced filters
// editable. Falls back to a textarea when the widget is unavailable.
widget: 'filter-condition',
dependsOn: ['object_name'],
// Deliberately NOT "leave empty to share everything" (#3896): an empty
// criteria never shared everything on purpose, it just failed open —
// ADR-0049 forbids the shape, and a rule saved without one is rejected.
description: 'Which records to share. Required — a rule must narrow the records it shares, so there is no "share every record" setting.',
group: 'Target',
}),
recipient_type: Field.select(
// `queue` returned with the queue entity (ADR-0142 D7): the evaluator
// expands it to the queue's members through the ownership-queue
// service. The seven values below are the ones the evaluator expands —
// `field` (#15072, the record-relative recipient ruled on #14103) per
// matched record, the other six once per rule. The list is held equal to
// the authorable `ShareRecipientType` by `field-recipient.test.ts`, so a
// stored row of any authorable kind is never refused at this select.
// Each option carries its label: the bare-string shorthand would show
// `unit_and_subordinates` as option text, while the help text below
// already names the options by these labels.
{
options: [
{ value: 'user', label: 'User' },
{ value: 'team', label: 'Team' },
{ value: 'business_unit', label: 'Business unit' },
{ value: 'position', label: 'Position' },
{ value: 'unit_and_subordinates', label: 'Business unit and subordinates' },
{ value: 'queue', label: 'Queue' },
{ value: 'field', label: 'Field' },
],
label: 'Recipient Type',
required: true,
defaultValue: 'business_unit',
// The engine detail this used to spell out (which tree is walked, which
// expansion is flat, the ADRs behind each) lives in the class doc above.
description: 'Who receives access. Picking a team, business unit, position or queue gives access to everyone in it. "Business unit and subordinates" also covers every unit below the one you pick. "Field" shares each record with the user or users held in one of its own fields — name that field as the recipient.',
group: 'Recipient',
},
),
recipient_id: Field.text({
label: 'Recipient',
required: true,
maxLength: 200,
// Rendered as a record picker whose target object follows recipient_type:
// sys_user / sys_team / sys_business_unit / sys_position. Stores the
// value the evaluator matches on — a record id for user/team/
// business_unit, the position NAME for `position`, and for `field`
// (#15072) the NAME of a user-typed field of the shared object, which the
// picker offers from the object named in the sibling `object_name`: its
// "holds users" predicate is a clause-for-clause copy of this plugin's
// own `fieldHoldsUsers`. Falls back to a text input when the widget is
// unavailable.
//
// `dependsOn` names BOTH siblings the widget reads — `recipient_type`
// picks the mode, and in `field` mode `object_name` decides whose columns
// are offered, the same dependency `criteria_json` declares a few lines
// up. Declaring only the first was masked by the form renderer handing
// widgets the WHOLE watched record instead of a `dependsOn`-scoped slice;
// a renderer that ever scoped it — which is what this key asks for —
// would drop the object name and degrade `field` mode to a plain text
// input in silence.
widget: 'recipient-picker',
dependsOn: ['recipient_type', 'object_name'],
description: 'The specific user, team, business unit, position or queue that receives access — or, for the "Field" recipient type, the name of the record field that holds the user or users to share with.',
group: 'Recipient',
}),
access_level: Field.select(
// `full` was removed because it was declared-but-unenforced (the reason
// `queue` was off the recipient list until ADR-0142 D7 enforced it). Labelled "Full Access (Transfer, Share,
// Delete)", it was matched only as `access_level in ('edit','full')` by
// the two enforcement sites, so it granted exactly what `edit` grants —
// offering it told an admin they had granted delete rights they had not
// (ADR-0078; #3865). Record sharing widens WHICH ROWS a principal
// reaches, never WHICH VERBS: delete/transfer come from ownership, the
// ADR-0057 DEPTH scopes, and admin scope. Stored `full` rows are
// normalised to `edit` on write and by the boot backfill.
{
options: [
{ value: 'read', label: 'Read' },
{ value: 'edit', label: 'Read and edit' },
],
label: 'Access Level',
required: true,
defaultValue: 'read',
description: 'What the recipients may do with the matching records — read them, or read and edit them.',
group: 'Recipient',
},
),
active: Field.boolean({
label: 'Active',
required: false,
defaultValue: true,
description: 'Turn off to withdraw the access this rule granted, without deleting the rule.',
group: 'Lifecycle',
}),
// ── Provenance (#2909 P0 — record-authoritative seed-not-clobber) ──
// Unified A4 (#2920) tri-state, the vocabulary the retired security
// catalog rows shared. Both columns are `readonly`:
// the engine strips them from non-system payloads (forge/clear-proof),
// while the seeder and the provenance stamp hook write with isSystem.
// NOTE deliberately no provenance write gate: sharing rules are a first-class admin authoring/tuning surface —
// admins may edit or deactivate package rules; the seeder simply stops
// overwriting rows once `customized` is stamped (ADR-0094 addendum).
managed_by: Field.select({
label: 'Managed By',
required: false,
readonly: true,
defaultValue: 'admin',
description:
'Where this rule came from: built into the platform, installed with an app, or created here in Setup.',
options: [
{ value: 'platform', label: 'Platform' },
{ value: 'package', label: 'Package' },
{ value: 'admin', label: 'Admin' },
],
group: 'System',
}),
customized: Field.boolean({
label: 'Customized',
required: false,
readonly: true,
defaultValue: false,
description:
'Set once you edit a rule that came with an app, so your changes are kept when the app is updated.',
group: 'System',
}),
created_at: Field.datetime({
label: 'Created At',
required: true,
defaultValue: 'NOW()',
readonly: true,
group: 'System',
}),
updated_at: Field.datetime({
label: 'Updated At',
required: false,
group: 'System',
}),
},
indexes: [
{ fields: ['object_name', 'active'] },
// [#8554] Scope spelled EXPLICITLY (ADR-0120 D1). On a DECLARED index bare
// `unique: true` is the positional spelling of `'global'` — the listed
// columns verbatim — so this was an installation-wide key on a tenant-scoped
// object. Measured live before the fix: org_jia 201 / org_yi 409
// UNIQUE_VIOLATION on the same name / org_yi unused name 201 / org_yi's own
// GET on the colliding name 0 rows. Sharing rules are authored by admins in
// the Studio criteria builder, so two organizations naming a rule
// `share_west_region` are not in conflict.
{ fields: ['name'], unique: 'organization' },
{ fields: ['organization_id'] },
],
});