@twinsearth/w2m-dsh-plugin 0.9.7 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Numeric authority: who may do what, expressed as an integer.
3
+ *
4
+ * ## Why an integer rather than a list of names
5
+ *
6
+ * The roadmap has authority appearing in four of its five layers -- the permission model, the approval
7
+ * flow, committee voting weights, and the defence hierarchy where a Coordinator outranks an Analyst. An
8
+ * enumerated set of role names would have to be re-partitioned by each of those layers, and each
9
+ * re-partition would be a place for two of them to disagree. An integer orders every actor on one axis,
10
+ * so "does A outrank B" is a comparison rather than a lookup table that has to be kept in step.
11
+ *
12
+ * The model is Unix-like on purpose: `matching_exempt_level` mirrors root's exemption from file
13
+ * permission checks. That is a *deliberate* hole in the rules, so it is the most carefully bounded thing
14
+ * in this file -- see `evaluate`.
15
+ *
16
+ * ## The four bands
17
+ *
18
+ * | Band | Range | May |
19
+ * |---|---|---|
20
+ * | ordinary | 0–49 | create goals and session data within its own scope |
21
+ * | captain | 50–69 | create team goals, manage team membership |
22
+ * | org admin | 70–89 | create organisation goals, register actors, set overrides |
23
+ * | system admin | 90+ | create global goals, configure across organisations, override any lower constraint |
24
+ *
25
+ * ## What this module deliberately does not do
26
+ *
27
+ * It does not decide *what* an action is, only whether an actor's level satisfies a stated rule. Action
28
+ * classification -- is this a write, a scope change, a registration -- belongs to whatever knows the
29
+ * action, and putting it here would mean this file had to be edited every time a new action appeared.
30
+ * That is how an authority model becomes a bottleneck and then gets bypassed.
31
+ */
32
+
33
+ /** The bands, as data rather than as branches, so a reader can enumerate them. */
34
+ export const AUTHORITY_BANDS = Object.freeze([
35
+ { name: 'ordinary', min: 0, max: 49, may: 'create goals and session data within its own scope' },
36
+ { name: 'captain', min: 50, max: 69, may: 'create team goals, manage team membership' },
37
+ { name: 'org_admin', min: 70, max: 89, may: 'create organisation goals, register actors, set overrides' },
38
+ { name: 'system_admin', min: 90, max: null, may: 'create global goals, configure across organisations, override any lower constraint' },
39
+ ]);
40
+
41
+ /** The highest level this model will accept. Above it, a level is a mistake rather than a promotion. */
42
+ export const AUTHORITY_MAX = 100;
43
+
44
+ /** Verdicts an authority check can reach. */
45
+ export const AUTHORITY_VERDICT = Object.freeze({
46
+ ALLOW: 'allow',
47
+ NEEDS_APPROVAL: 'needs_approval',
48
+ DENY: 'deny',
49
+ });
50
+
51
+ /**
52
+ * Is this a usable authority level?
53
+ *
54
+ * Rejects non-integers as well as out-of-range values. `50.5` is not "between captain and org admin"; it
55
+ * is a value nobody defined, and accepting it would make band boundaries behave differently for actors
56
+ * that are supposed to be equal.
57
+ *
58
+ * @param {unknown} level
59
+ * @returns {boolean}
60
+ */
61
+ export function isAuthorityLevel(level) {
62
+ return Number.isInteger(level) && level >= 0 && level <= AUTHORITY_MAX;
63
+ }
64
+
65
+ /**
66
+ * Which band a level falls in, or `null` for a value that is not a level.
67
+ *
68
+ * @param {unknown} level
69
+ * @returns {{name: string, min: number, max: number|null, may: string}|null}
70
+ */
71
+ export function bandOf(level) {
72
+ if (!isAuthorityLevel(level)) return null;
73
+ for (const band of AUTHORITY_BANDS) {
74
+ if (level >= band.min && (band.max === null || level <= band.max)) return band;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /**
80
+ * A rule about an action.
81
+ *
82
+ * `minLevel` is the floor for performing it at all. `matchingExemptLevel`, when present, is the level at
83
+ * or above which the *membership* part of the rule is skipped -- the Unix-root analogy. `scope` names what
84
+ * the action is performed against, so a refusal can say which rule refused it.
85
+ *
86
+ * @typedef {object} AuthorityRule
87
+ * @property {string} action
88
+ * @property {number} minLevel
89
+ * @property {number|null} [matchingExemptLevel]
90
+ * @property {string|null} [scope]
91
+ * @property {string} [why]
92
+ */
93
+
94
+ /**
95
+ * Cheap structural validation, separate from evaluation.
96
+ *
97
+ * A malformed rule is a configuration defect and should be caught where it is written, not silently
98
+ * treated as a rule that permits nothing -- "denied" and "misconfigured" look identical from the outside
99
+ * and only one of them means someone should fix something.
100
+ *
101
+ * @param {unknown} rule
102
+ * @returns {string|null} a reason, or `null` when the rule is well formed.
103
+ */
104
+ export function ruleProblem(rule) {
105
+ if (rule === null || typeof rule !== 'object') return 'rule must be an object';
106
+ if (typeof rule.action !== 'string' || rule.action.trim() === '') return 'rule.action must be a non-empty string';
107
+ if (!isAuthorityLevel(rule.minLevel)) return `rule.minLevel must be an integer 0..${AUTHORITY_MAX}`;
108
+ if (rule.matchingExemptLevel !== undefined && rule.matchingExemptLevel !== null
109
+ && !isAuthorityLevel(rule.matchingExemptLevel)) {
110
+ return `rule.matchingExemptLevel must be an integer 0..${AUTHORITY_MAX} or null`;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Evaluate one rule against one actor.
117
+ *
118
+ * The order of the three checks is the whole security property, so it is stated rather than implied:
119
+ *
120
+ * 1. **Exemption**, and only when the rule names one. An exempt actor skips the *membership* check --
121
+ * not the level check, and not the existence of the rule. This is the Unix-root analogy: root
122
+ * bypasses a file's permission bits, not the fact that the file must exist.
123
+ * 2. **Membership**, when the rule has a scope and the caller supplied one. An actor outside the scope
124
+ * needs a supervisor, not a refusal, which is why this yields `needs_approval` rather than `deny`.
125
+ * 3. **Level**, the plain floor.
126
+ *
127
+ * A rule with no `matchingExemptLevel` exempts **nobody**. That is the decision most worth stating: the
128
+ * tempting default is "absent means unconstrained", and it is wrong in the one direction that matters --
129
+ * a rule author who forgets the field would silently create an unrestricted action.
130
+ *
131
+ * @param {AuthorityRule} rule
132
+ * @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
133
+ * @returns {{verdict: string, action: string|null, level: number|null, exempt: boolean, reason: string}}
134
+ */
135
+ export function evaluate(rule, actor) {
136
+ const problem = ruleProblem(rule);
137
+ if (problem !== null) {
138
+ return {
139
+ verdict: AUTHORITY_VERDICT.DENY,
140
+ action: rule?.action ?? null,
141
+ level: actor?.level ?? null,
142
+ exempt: false,
143
+ reason: `malformed rule: ${problem}`,
144
+ };
145
+ }
146
+ if (!isAuthorityLevel(actor?.level)) {
147
+ return {
148
+ verdict: AUTHORITY_VERDICT.DENY,
149
+ action: rule.action,
150
+ level: actor?.level ?? null,
151
+ exempt: false,
152
+ reason: `actor has no usable authority level (got ${JSON.stringify(actor?.level ?? null)})`,
153
+ };
154
+ }
155
+
156
+ const exemptLevel = rule.matchingExemptLevel ?? null;
157
+ const exempt = exemptLevel !== null && actor.level >= exemptLevel;
158
+
159
+ // 2. Membership, skipped entirely for an exempt actor.
160
+ if (!exempt && rule.scope !== undefined && rule.scope !== null && actor.inScope === false) {
161
+ return {
162
+ verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
163
+ action: rule.action,
164
+ level: actor.level,
165
+ exempt: false,
166
+ reason: `outside the scope of "${rule.action}" (${rule.scope}); a supervisor within it may approve`,
167
+ };
168
+ }
169
+
170
+ // 3. The floor. Checked even for an exempt actor -- see the header.
171
+ if (actor.level < rule.minLevel) {
172
+ return {
173
+ verdict: AUTHORITY_VERDICT.NEEDS_APPROVAL,
174
+ action: rule.action,
175
+ level: actor.level,
176
+ exempt,
177
+ reason: `level ${actor.level} is below the minimum ${rule.minLevel} for "${rule.action}"`,
178
+ };
179
+ }
180
+
181
+ return {
182
+ verdict: AUTHORITY_VERDICT.ALLOW,
183
+ action: rule.action,
184
+ level: actor.level,
185
+ exempt,
186
+ reason: exempt
187
+ ? `allowed: level ${actor.level} meets the exemption ${exemptLevel} for "${rule.action}"`
188
+ : `allowed: level ${actor.level} meets the minimum ${rule.minLevel} for "${rule.action}"`,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Evaluate several rules, stopping at the first one that is not a plain allow.
194
+ *
195
+ * ## Why it stops, rather than evaluating everything
196
+ *
197
+ * An earlier draft evaluated all rules and returned the strictest verdict. It is the more obvious design
198
+ * and the less useful one, for two reasons that both matter to the person reading the answer:
199
+ *
200
+ * * **`decisive` must be unique.** With every rule evaluated, a list can hold several non-allow
201
+ * decisions and the caller has to pick one -- and whichever it picks, the *stated* reason for a
202
+ * refusal stops being the reason. An operator asking "why was this not allowed" needs one rule.
203
+ * * **The unevaluated rules may not even apply.** A rule is evaluated against an actor, and an actor's
204
+ * scope can be learned from the outcome of an earlier rule; continuing past a gate would evaluate
205
+ * later rules against a context the gate had already made moot.
206
+ *
207
+ * Stopping at the *first* non-allow -- not merely at the first deny -- follows from the same argument. A
208
+ * gate is a gate whether it says `deny` or `needs_approval`, and in both cases the caller's next action
209
+ * is determined and the rest of the list is decoration.
210
+ *
211
+ * @param {AuthorityRule[]} rules
212
+ * @param {{level: number, inScope?: boolean|null, id?: string|null}} actor
213
+ * @returns {{verdict: string, decisions: object[], decisive: object|null}}
214
+ */
215
+ export function evaluateAll(rules, actor) {
216
+ const decisions = [];
217
+ for (const rule of Array.isArray(rules) ? rules : []) {
218
+ const decision = evaluate(rule, actor);
219
+ decisions.push(decision);
220
+ if (decision.verdict !== AUTHORITY_VERDICT.ALLOW) {
221
+ return { verdict: decision.verdict, decisions, decisive: decision };
222
+ }
223
+ }
224
+ return { verdict: AUTHORITY_VERDICT.ALLOW, decisions, decisive: null };
225
+ }
226
+
227
+ /**
228
+ * The level required to sign off on a refusal.
229
+ *
230
+ * One band up, floored at the captain band, because the document's approval flow is a chain --
231
+ * captain, then org admin, then system admin -- and a chain that can be satisfied by the actor's own peer
232
+ * group is not a chain. An actor already at the top band has no higher approver, so the top band signs
233
+ * for itself; saying otherwise would make a system-admin action unapprovable.
234
+ *
235
+ * @param {number} level
236
+ * @returns {number|null}
237
+ */
238
+ export function requiredApproverLevel(level) {
239
+ const band = bandOf(level);
240
+ if (band === null) return null;
241
+ if (band.name === 'system_admin') return 90;
242
+ const higher = AUTHORITY_BANDS[AUTHORITY_BANDS.indexOf(band) + 1];
243
+ return higher === undefined ? null : higher.min;
244
+ }
245
+
246
+ /**
247
+ * Is `approver` allowed to sign off on `subject`'s pending action?
248
+ *
249
+ * Strictly higher, not equal-or-higher. Two captains approving each other is a peer ring, and the whole
250
+ * point of the chain is that a decision can be escalated past the level that wanted it. Self-approval is
251
+ * refused for the same reason, stated separately because it is the case an implementation is most likely
252
+ * to let through by comparing only levels.
253
+ *
254
+ * @param {{level: number, id?: string|null}} approver
255
+ * @param {{level: number, id?: string|null}} subject
256
+ * @returns {{ok: boolean, reason: string, required: number|null}}
257
+ */
258
+ export function mayApprove(approver, subject) {
259
+ const required = requiredApproverLevel(subject?.level);
260
+ if (required === null) {
261
+ return { ok: false, required: null, reason: `subject has no usable authority level (got ${JSON.stringify(subject?.level ?? null)})` };
262
+ }
263
+ if (!isAuthorityLevel(approver?.level)) {
264
+ return { ok: false, required, reason: `approver has no usable authority level (got ${JSON.stringify(approver?.level ?? null)})` };
265
+ }
266
+ if (approver.id !== undefined && approver.id !== null && approver.id === subject?.id) {
267
+ return { ok: false, required, reason: 'an actor may not approve its own request' };
268
+ }
269
+ if (approver.level < required) {
270
+ return { ok: false, required, reason: `approver level ${approver.level} is below the required ${required}` };
271
+ }
272
+ return { ok: true, required, reason: `approver level ${approver.level} meets the required ${required}` };
273
+ }
274
+
275
+ /**
276
+ * Compose a child actor's authority from its parent's.
277
+ *
278
+ * **This never increases it.** No rule in the source design grants delegation, and the default an
279
+ * implementation reaches for -- "a captain's delegate is a captain" -- turns any single compromised
280
+ * actor into an escalation path. Where the source is silent, the safe reading is the narrow one, and the
281
+ * `reason` says which of the two rules applied so a later reader can tell a deliberate cap from a bug.
282
+ *
283
+ * @param {{level: number, id?: string|null}} parent
284
+ * @param {{level?: number, cap?: number|null, id?: string|null}} child
285
+ * @returns {{level: number|null, capped: boolean, reason: string}}
286
+ */
287
+ export function deriveAuthority(parent, child = {}) {
288
+ if (!isAuthorityLevel(parent?.level)) {
289
+ return { level: null, capped: false, reason: `parent has no usable authority level (got ${JSON.stringify(parent?.level ?? null)})` };
290
+ }
291
+ if (child.level !== undefined && !isAuthorityLevel(child.level)) {
292
+ return { level: null, capped: false, reason: `child level is not usable (got ${JSON.stringify(child.level)})` };
293
+ }
294
+ const asked = child.level ?? parent.level;
295
+ const cap = isAuthorityLevel(child.cap) ? child.cap : parent.level;
296
+ const ceiling = Math.min(parent.level, cap);
297
+ const level = Math.min(asked, ceiling);
298
+ return {
299
+ level,
300
+ capped: level < asked,
301
+ reason: level < asked
302
+ ? `reduced from ${asked} to ${level}: a derived actor never exceeds its parent (${parent.level}) or its cap (${cap})`
303
+ : `granted ${level}: within parent (${parent.level}) and cap (${cap})`,
304
+ };
305
+ }