Repository navigation
Expand file tree
/
Copy pathsys-position.object.ts
More file actions
412 lines (393 loc) · 18.7 KB
/
Copy pathsys-position.object.ts
File metadata and controls
412 lines (393 loc) · 18.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
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { ObjectSchema, Field } from '@objectstack/spec/data';
import { reservedIdentityNamesCelList, reservedIdentityNameMessage } from './reserved-identity-names.js';
/**
* [#15972] The CEL predicate behind the `reserved_identity_name` rule below,
* assembled here so the object literal reads as one line and the two halves of
* the condition can each carry their own note.
*
* ⚠️ `&&` order is load-bearing. CEL absorbs an error on one side of `&&` when
* the other side is `false`, so putting the reserved-name test FIRST means the
* provenance test is only ever evaluated for a write that already spells a
* reserved name. Every other name short-circuits out before `managed_by` is
* read at all — which is what keeps the negative control (every other name
* still writes) independent of whatever `managed_by` happens to hold.
*/
const RESERVED_IDENTITY_NAME_CONDITION =
`record.name in ${reservedIdentityNamesCelList()}`
// The framework's OWN catalog rows are the exception, and the only one:
// `bootstrapBuiltinRoles` seeds exactly these names, per organization,
// stamped `managed_by: 'platform'` under an `isSystem` context. `system` is
// the legacy pre-A4 spelling of the same provenance — kept in lockstep with
// `SYSTEM_ROW_PROVENANCE` (security-plugin.ts), which maps BOTH to "the
// platform" — so a legacy row is not frozen by an invariant that lands after
// it. ⛔ `package` / `config` are deliberately NOT exempt: a stack that
// declares a position named `org_admin` is repurposing a built-in identity,
// which is the same defect arriving through the supply chain, and
// `bootstrapDeclaredPositions` stamps no provenance at all (the row defaults
// to `admin`), so it is refused here like any other tenant-grade write.
//
// A tenant cannot reach the exemption by CLAIMING it: `managed_by` is
// `readonly`, and the admin-door provenance gate refuses a payload that
// spells `platform`/`package` outright.
+ ` && !(record.managed_by in ['platform', 'system'])`;
/**
* sys_position — Position definitions (ADR-0090 D3).
*
* A position (岗位) is the flat capability-DISTRIBUTION group: users hold
* positions (`sys_user_position`), positions bind permission sets
* (`sys_position_permission_set`). Positions carry no capability of their
* own and no hierarchy — the visibility tree lives on `sys_business_unit`.
*
* @namespace sys
*/
export const SysPosition = ObjectSchema.create({
name: 'sys_position',
label: 'Position',
pluralLabel: 'Positions',
icon: 'shield',
isSystem: true,
managedBy: 'config',
// ADR-0010 §3.7 — RBAC primitive; tenants may add custom rows
// (created via UI / API) but the schema itself is locked.
// ADR-0068 D3: position-DEFINITION authority follows the isolation boundary.
// Framework-reserved built-in identities (platform_admin / org_*) and the
// ADR-0090 D9 audience anchors (everyone / guest) are seeded with
// `managed_by = 'platform'` (A4 #2920 unified vocab; formerly 'system') and
// MUST NOT be repurposed by a tenant; ad-hoc position definitions in a shared
// cross-tenant kernel namespace are forbidden.
protection: {
lock: 'no-overlay',
reason: 'RBAC schema is platform-defined — see ADR-0010.',
docsUrl: 'https://objectstack.ai/docs/references/shared/protection',
},
description: 'Position definitions for capability distribution (ADR-0090)',
displayNameField: 'label',
nameField: 'label', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
titleFormat: '{label}',
highlightFields: ['label', 'name', 'managed_by', 'active', 'is_default'],
// Custom actions — positions drive capability distribution and are edited
// rarely but require the four high-frequency sysadmin affordances every IdP
// (Salesforce, ServiceNow, Okta) ships: activate/deactivate (lifecycle
// without losing assignments), mark default (auto-assign to new users),
// and clone (template for new positions). All operations hit the generic
// data CRUD endpoint exposed by `apiEnabled` — no custom server route
// required because `managedBy: 'config'` allows direct mutation.
actions: [
{
name: 'activate_position',
label: 'Activate Position',
icon: 'circle-check',
variant: 'secondary',
mode: 'custom',
locations: ['list_item', 'record_header'],
type: 'api',
method: 'PATCH',
target: '/api/v1/data/sys_position/{id}',
bodyExtra: { active: true },
successMessage: 'Position activated',
refreshAfter: true,
},
{
name: 'deactivate_position',
label: 'Deactivate Position',
icon: 'circle-off',
variant: 'danger',
mode: 'custom',
locations: ['list_item', 'record_header'],
type: 'api',
method: 'PATCH',
target: '/api/v1/data/sys_position/{id}',
bodyExtra: { active: false },
confirmText: 'Deactivate this position? Users keep their assignment but the position stops granting permissions until re-activated.',
successMessage: 'Position deactivated',
refreshAfter: true,
},
{
name: 'set_default_position',
label: 'Set as Default',
icon: 'star',
variant: 'secondary',
mode: 'custom',
locations: ['list_item', 'record_header'],
type: 'api',
method: 'PATCH',
target: '/api/v1/data/sys_position/{id}',
bodyExtra: { is_default: true },
confirmText: 'Make this the default position for new users? Existing users are unaffected.',
successMessage: 'Default position updated',
refreshAfter: true,
},
{
// Clone — POST a new sys_position row pre-filled from the source. The
// dialog asks only for the new API name / label so the operator
// can rename atomically; description is carried over via defaultFromRow.
// (Until #9885 this also copied the retired `permissions` column — a
// copy of a value nothing ever wrote; see the retirement note at the
// field group below. A clone does NOT copy permission-set bindings:
// `sys_position_permission_set` rows are runtime state, bound in Setup.)
name: 'clone_position',
label: 'Clone Position',
icon: 'copy',
variant: 'secondary',
mode: 'custom',
locations: ['list_item', 'record_header'],
type: 'api',
method: 'POST',
target: '/api/v1/data/sys_position',
bodyExtra: { is_default: false, active: true },
successMessage: 'Position cloned',
refreshAfter: true,
params: [
{ name: 'label', label: 'New Display Name', type: 'text', required: true },
// [#8468] The clone dialog is where an admin types a NEW position name,
// so it is the one place the scope has to be right at the moment of
// authoring: the name must be free within THIS organization, not across
// the installation.
{ name: 'name', label: 'New API Name', type: 'text', required: true, helpText: 'snake_case machine name, unique per organization' },
{ field: 'description', defaultFromRow: true },
],
},
],
listViews: {
active: {
type: 'grid',
name: 'active',
label: 'Active',
data: { provider: 'object', object: 'sys_position' },
columns: ['label', 'name', 'managed_by', 'is_default', 'updated_at'],
filter: [{ field: 'active', operator: 'equals', value: true }],
sort: [{ field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
default_positions: {
type: 'grid',
name: 'default_positions',
label: 'Default',
data: { provider: 'object', object: 'sys_position' },
columns: ['label', 'name', 'managed_by', 'description', 'active'],
filter: [{ field: 'is_default', operator: 'equals', value: true }],
sort: [{ field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
custom: {
type: 'grid',
name: 'custom',
label: 'Custom',
data: { provider: 'object', object: 'sys_position' },
columns: ['label', 'name', 'managed_by', 'active', 'updated_at'],
filter: [{ field: 'is_default', operator: 'equals', value: false }],
sort: [{ field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
all_positions: {
type: 'grid',
name: 'all_positions',
label: 'All',
data: { provider: 'object', object: 'sys_position' },
columns: ['label', 'name', 'managed_by', 'active', 'is_default', 'updated_at'],
sort: [{ field: 'label', order: 'asc' }],
pagination: { pageSize: 50 },
},
},
fields: {
// ── Identity ─────────────────────────────────────────────────
label: Field.text({
label: 'Display Name',
required: true,
searchable: true,
maxLength: 255,
group: 'Identity',
}),
name: Field.text({
label: 'API Name',
required: true,
searchable: true,
maxLength: 100,
// [#8468] "unique per organization", not "unique". The bare wording
// asserted the installation-wide reading the declared index accidentally
// materialized, so the accident reached admins as contract.
description:
'Machine name for the position, unique per organization ' +
'(e.g. sales_manager, hr_specialist)',
group: 'Identity',
}),
description: Field.textarea({
label: 'Description',
required: false,
group: 'Identity',
}),
// ── Configuration ────────────────────────────────────────────
// RETIRED (#9885, ADR-0049 enforce-or-remove, maintainer ruling
// 2026-08-20): `permissions` — a "JSON-serialized array of permission
// strings" textarea — was declared here while NO producer ever wrote it
// and NO runtime path ever read it (the object-scoped census read all
// sys_position-naming files; the bootstrap writers set label /
// description / managed_by / active / is_default only). Grants reach a
// position ONLY through permission-set bindings —
// `sys_position_permission_set` rows, resolved by position `name` in
// resolve-authz-context — so the declared column told an author (human
// or AI) that direct position-level permission strings are a platform
// capability; they are not. Re-declaring the column without a runtime
// reader in the same change is the exact defect the ruling removed.
// An undeclared field in a write is refused loudly by the engine's
// schema preflight (400 INVALID_FIELD); existing physical columns are
// untouched (ADR-0045 schema sync is additive).
// ── Status ───────────────────────────────────────────────────
active: Field.boolean({
label: 'Active',
defaultValue: true,
group: 'Status',
}),
is_default: Field.boolean({
label: 'Default Position',
defaultValue: false,
description: 'Automatically assigned to new users',
group: 'Status',
}),
// [ADR-0091 D3] Opt-in to self-service delegation of duty (职务代理). When
// true, a holder may assign this position to a delegate — time-boxed,
// reasoned, dual-audited — without being a delegated administrator. Admin-
// ish positions (those distributing an adminScope set) MUST NOT set this;
// the D7 lint and the D12 gate reject that combination.
delegatable: Field.boolean({
label: 'Delegatable',
defaultValue: false,
description: 'ADR-0091 D3: holders may self-service delegate this position, time-boxed.',
group: 'Status',
}),
// ── System ───────────────────────────────────────────────────
// [A4 #2920] Unified provenance tri-state — platform / package / admin —
// shared verbatim with sys_capability and sys_permission_set. Converted from
// free `text` to a constrained `select`. `platform` = a framework-reserved
// built-in identity position (seeded by bootstrapBuiltinRoles, read-only);
// `package` = stack/package-declared; `admin` = tenant-created in Setup.
// Back-compat: legacy rows may carry system (== platform) / config (== package)
// / user (== admin); the boot normalizer heals them to the canonical vocab, and
// the system-row write gate (SYSTEM_ROW_PROVENANCE, security-plugin.ts) guards
// both vocabularies — keep it in lockstep with any change here (#2926 ①).
// Built-in (`platform`) rows self-heal on the next bootstrap upsert.
managed_by: Field.select({
label: 'Managed By',
readonly: true,
defaultValue: 'admin',
description:
'Record provenance, on the one platform / package / admin vocabulary that capabilities, ' +
'permission sets and positions all share: platform = framework built-in ' +
'(read-only) / package = stack/package-declared / admin = tenant-created. Legacy rows ' +
'may carry system (== platform) / config (== package) / user (== admin).',
options: [
{ value: 'platform', label: 'Platform' },
{ value: 'package', label: 'Package' },
{ value: 'admin', label: 'Admin' },
],
group: 'System',
}),
id: Field.text({
label: 'Position ID',
required: true,
readonly: true,
group: 'System',
}),
created_at: Field.datetime({
label: 'Created At',
defaultValue: 'NOW()',
readonly: true,
group: 'System',
}),
updated_at: Field.datetime({
label: 'Updated At',
defaultValue: 'NOW()',
readonly: true,
group: 'System',
}),
},
indexes: [
// [ADR-0120 D1, #8468] `'organization'`, NOT bare `true`.
//
// Positions are admin-authored (`managed_by: 'admin'` by default, created
// in Setup and by the `clone_position` action above), and the object takes
// no `tenancy` opt-out, so `organization_id` is injected. A position name
// is therefore one holder per ORGANIZATION, not one across the whole
// installation.
//
// Bare `true` on a DECLARED index is the positional spelling of `'global'`
// — the listed columns VERBATIM (see `normalizeDeclaredIndex`, which
// prepends the organization key part only for the explicit spelling). It is
// NOT the field-level `true`, which has meant per-organization since #3696;
// that divergence is "the #4986 trap" named in
// `packages/lint/src/data-model-rules.ts`, and this declaration was the
// third instance of it in the platform's own objects.
//
// Measured on a real engine before the fix (two organizations, same name):
// org_jia POST name=probe_pos_xtenant → 201
// org_yi POST the SAME name → 409 UNIQUE_VIOLATION
// org_yi POST an unused name → 201
// org_yi GET that name → total 0
// A per-value refusal on a row the caller cannot read is a cross-tenant
// existence oracle, plus a dead end: the second organization could never
// name a position `sales_manager` if any other organization already had.
//
// The maintainer ruling of 2026-08-13 settles the family: an admin-authored
// name on a tenant-scoped object is scoped per organization. The hierarchy
// counter-argument does not apply here in any case — positions are
// deliberately FLAT (ADR-0090 D3, finalizing ADR-0057 D5); there is no
// `parent_id` on this object and no position tree to share a namespace.
//
// Platform-seeded rows (`bootstrapBuiltinRoles`, `managed_by: 'platform'`)
// carry no organization, and the organization key part is NULL-safe
// (`COALESCE(organization_id, '__global__')`, ADR-0120 D3), so they remain
// unique among THEMSELVES and the bootstrap upsert-by-name is unaffected.
{ fields: ['name'], unique: 'organization' },
{ fields: ['active'] },
],
enable: {
trackHistory: true,
searchable: true,
apiEnabled: true,
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
},
// ── [#15972] Reserved built-in identity names ────────────────────
//
// The prose at the head of this file has said since ADR-0068 that the
// framework-reserved built-in identities "MUST NOT be repurposed by a
// tenant". ⚠️ That was a COMMENT, not a gate — the names stayed writable,
// and every defence against a tenant-minted `platform_admin` was a READER
// choosing to consult the capability rung rather than the name. This array
// is the enforcement of the sentence that was already here.
//
// Why the object's `validations`, and not a gate in the security plugin:
// the plugin's name gates (the curated-capability one, the provenance one)
// guard the ADMIN DOOR. A rule declared here is evaluated by the ENGINE
// (`objectql`'s rule validator) on insert, by-id update and multi-row
// update, so the data API, the seeders and metadata import are all covered
// by ONE refusal carrying ONE code (`VALIDATION_FAILED`) — which is what
// stops the two doors answering the same condition with two vocabularies.
//
// ⚠️ An INVARIANT, not a transition gate (see `ScriptValidationSchema`): the
// predicate is re-evaluated against the merged record on every write, so a
// row that ALREADY spells a reserved name is refused on any edit until it is
// renamed — frozen, not bricked, and deliberately so on a security invariant.
// Nothing here rewrites such a row; the read-only census
// (`scripts/measure-reserved-identity-name-census.mjs`) is what reports them.
validations: [
{
// `cross_field`, not `script`: the condition genuinely reads two columns
// (the name AND its provenance), and only this variant carries `fields`,
// which is what attaches the violation to `name` instead of `_record` so
// a form can point at the offending input.
type: 'cross_field',
name: 'reserved_identity_name',
label: 'Reserved built-in identity name',
description:
'ADR-0068 D2 reserves the built-in identity names for the framework. Only the platform’s own '
+ 'catalog seed may spell one; a tenant- or package-authored position must choose another name.',
fields: ['name', 'managed_by'],
condition: { dialect: 'cel', source: RESERVED_IDENTITY_NAME_CONDITION },
severity: 'error',
message: reservedIdentityNameMessage('name'),
},
],
});