@jwilleke/ngdpbase-types 5.42.0 → 5.44.0

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.
Files changed (74) hide show
  1. package/lib/src/context/ApiContext.d.ts +10 -3
  2. package/lib/src/context/ApiContext.js +37 -4
  3. package/lib/src/context/BaseContext.d.ts +20 -20
  4. package/lib/src/context/BaseContext.js +41 -24
  5. package/lib/src/context/JobContext.d.ts +31 -0
  6. package/lib/src/context/JobContext.js +34 -1
  7. package/lib/src/ice/runExport.d.ts +8 -3
  8. package/lib/src/ice/searchExportSource.d.ts +44 -0
  9. package/lib/src/ice/types.d.ts +6 -0
  10. package/lib/src/managers/AddonsManager.d.ts +0 -28
  11. package/lib/src/managers/AgentTokenManager.d.ts +8 -0
  12. package/lib/src/managers/AttachmentManager.d.ts +11 -0
  13. package/lib/src/managers/AuditManager.d.ts +0 -1
  14. package/lib/src/managers/AuthManager.d.ts +38 -0
  15. package/lib/src/managers/AuthManager.js +84 -12
  16. package/lib/src/managers/BackupManager.d.ts +9 -2
  17. package/lib/src/managers/BackupManager.js +42 -19
  18. package/lib/src/managers/BaseManager.d.ts +17 -3
  19. package/lib/src/managers/BaseManager.js +12 -8
  20. package/lib/src/managers/ConfigurationManager.d.ts +28 -0
  21. package/lib/src/managers/ConsentManager.d.ts +1 -1
  22. package/lib/src/managers/DatabaseManager.js +17 -8
  23. package/lib/src/managers/EmailManager.d.ts +1 -1
  24. package/lib/src/managers/ExportManager.d.ts +1 -44
  25. package/lib/src/managers/ExportManager.js +3 -115
  26. package/lib/src/managers/ImportManager.d.ts +7 -0
  27. package/lib/src/managers/PageManager.d.ts +194 -23
  28. package/lib/src/managers/PolicyEvaluator.d.ts +182 -24
  29. package/lib/src/managers/ShareManager.d.ts +3 -2
  30. package/lib/src/managers/ThemeManager.d.ts +20 -1
  31. package/lib/src/managers/UserManager.d.ts +19 -5
  32. package/lib/src/managers/ValidationManager.d.ts +3 -1
  33. package/lib/src/providers/AgentTokenAuthProvider.js +1 -1
  34. package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
  35. package/lib/src/providers/FileLoggingProvider.d.ts +12 -1
  36. package/lib/src/providers/FileLoggingProvider.js +37 -4
  37. package/lib/src/security/PolicyDecisionPoint.d.ts +107 -4
  38. package/lib/src/security/PolicyInformationPoint.d.ts +113 -7
  39. package/lib/src/security/ownedRecords.d.ts +69 -0
  40. package/lib/src/security/permissions.generated.d.ts +8 -0
  41. package/lib/src/security/signedOutWrites.d.ts +69 -0
  42. package/lib/src/security/signedOutWrites.js +146 -0
  43. package/lib/src/types/Page.d.ts +0 -7
  44. package/lib/src/types/Policy.d.ts +65 -8
  45. package/lib/src/types/Policy.js +6 -0
  46. package/lib/src/types/Provider.d.ts +12 -0
  47. package/lib/src/types/User.d.ts +6 -0
  48. package/lib/src/utils/addonConfigLayer.d.ts +6 -3
  49. package/lib/src/utils/addonConfigLayer.js +61 -3
  50. package/lib/src/utils/addonHost.d.ts +25 -0
  51. package/lib/src/utils/addonHost.js +29 -1
  52. package/lib/src/utils/addonPageSync.d.ts +0 -12
  53. package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
  54. package/lib/src/utils/auditEventNames.generated.js +2 -0
  55. package/lib/src/utils/auditEvents.d.ts +2 -0
  56. package/lib/src/utils/auditEvents.js +3 -1
  57. package/lib/src/utils/availability.d.ts +15 -0
  58. package/lib/src/utils/availability.js +88 -0
  59. package/lib/src/utils/frontmatterAccess.d.ts +15 -24
  60. package/lib/src/utils/legacyStoreKeys.d.ts +16 -0
  61. package/lib/src/utils/pageTitleConflict.d.ts +32 -0
  62. package/lib/src/utils/pageWritePolicies.d.ts +74 -0
  63. package/lib/src/utils/permissionCatalog.d.ts +2 -0
  64. package/lib/src/utils/permissionCatalog.js +19 -0
  65. package/lib/src/utils/plainAttention.d.ts +19 -0
  66. package/lib/src/utils/plainAttention.js +18 -0
  67. package/lib/src/utils/policyAttributes.d.ts +232 -0
  68. package/lib/src/utils/policyAttributes.js +404 -0
  69. package/lib/src/utils/privateStoreAccess.d.ts +24 -4
  70. package/lib/src/utils/privateStoreAccess.js +19 -4
  71. package/lib/src/utils/roleGrants.d.ts +1 -1
  72. package/lib/src/utils/username.d.ts +13 -2
  73. package/lib/src/utils/username.js +18 -3
  74. package/package.json +7 -7
@@ -0,0 +1,404 @@
1
+ /** The types an attribute can be declared with. */
2
+ export const ATTRIBUTE_TYPES = ['string', 'integer', 'boolean', 'identity', 'list'];
3
+ /**
4
+ * The datatypes a condition may name: the XACML 3.0 JSON profile's short
5
+ * names. Only `string`, `integer` and `boolean` have a function today, so a
6
+ * condition naming any other is refused as a datatype its function does not take.
7
+ */
8
+ export const DATATYPES = ['string', 'integer', 'boolean', 'double', 'date', 'time', 'dateTime', 'anyURI'];
9
+ /**
10
+ * How deep a condition may nest. Every function node counts, arguments that
11
+ * are attributes or values do not, so `or` > `and` > `not` > `string-equal` is 4
12
+ * and one more level is refused.
13
+ */
14
+ export const MAX_CONDITION_DEPTH = 4;
15
+ /**
16
+ * How wide a condition may be (#2261). Depth alone leaves the tree as wide as
17
+ * a config file can make it, and the evaluator walks it for every page a
18
+ * listing or search asks about. A literal `value` list in an `is-in`, the
19
+ * arguments of one `and` or `or`, and the function nodes of the whole condition
20
+ * are each capped.
21
+ */
22
+ export const MAX_IN_ITEMS = 256;
23
+ export const MAX_GROUP_CHILDREN = 256;
24
+ export const MAX_CONDITION_NODES = 512;
25
+ /**
26
+ * The functions that compare two arguments, by XACML 3.0 name (core spec,
27
+ * appendix A.3), in lowercase. `X-is-in` takes a value, then a bag: an attribute
28
+ * that is a list, or a literal whose `value` is an array.
29
+ */
30
+ export const COMPARISON_FUNCTIONS = {
31
+ 'string-equal': { type: 'string', kind: 'equal' },
32
+ 'integer-equal': { type: 'integer', kind: 'equal' },
33
+ 'boolean-equal': { type: 'boolean', kind: 'equal' },
34
+ 'string-is-in': { type: 'string', kind: 'is-in' },
35
+ 'integer-is-in': { type: 'integer', kind: 'is-in' },
36
+ 'integer-greater-than': { type: 'integer', kind: 'greater-than' },
37
+ 'integer-greater-than-or-equal': { type: 'integer', kind: 'greater-than-or-equal' },
38
+ 'integer-less-than': { type: 'integer', kind: 'less-than' },
39
+ 'integer-less-than-or-equal': { type: 'integer', kind: 'less-than-or-equal' }
40
+ };
41
+ /** The functions that combine conditions: XACML's logical functions. `not` takes one condition, these take a list. */
42
+ export const GROUP_FUNCTIONS = ['and', 'or'];
43
+ export const NOT_FUNCTION = 'not';
44
+ /** Every function, in the order the validator lists them. */
45
+ export const CONDITION_FUNCTIONS = [...Object.keys(COMPARISON_FUNCTIONS), ...GROUP_FUNCTIONS, NOT_FUNCTION];
46
+ /** The keys of an argument: an attribute or a literal, and its datatype. */
47
+ export const OPERAND_KEYS = ['attributeid', 'value', 'datatype'];
48
+ /**
49
+ * The shape before #2330, key by key, and what replaces it, so the validator
50
+ * can say what to write. Not read by the evaluator: an old condition never holds.
51
+ */
52
+ export const LEGACY_CONDITION_KEYS = {
53
+ attribute: '{ "attributeid": …, "datatype": … } as an argument of a function',
54
+ equals: 'string-equal, integer-equal or boolean-equal',
55
+ 'not-equals': 'not around string-equal, integer-equal or boolean-equal',
56
+ in: 'string-is-in or integer-is-in',
57
+ 'greater-or-equal': 'integer-greater-than-or-equal',
58
+ 'less-or-equal': 'integer-less-than-or-equal',
59
+ 'any-of': 'or',
60
+ 'all-of': 'and'
61
+ };
62
+ /** The one example the validator shows for the old shape. */
63
+ export const CONDITION_EXAMPLE = '{ "string-equal": [ { "attributeid": "subject:department-id", "datatype": "string" }, { "value": "sales", "datatype": "string" } ] }';
64
+ /** The datatype an attribute of a declared type is referenced with: an identity is a string. A list has the datatype of its entries. */
65
+ export function datatypeOf(attributeType) {
66
+ if (attributeType === 'identity')
67
+ return 'string';
68
+ if (attributeType === 'list')
69
+ return null;
70
+ return attributeType;
71
+ }
72
+ /** The type a literal value has, or `null` when it is not a usable literal. */
73
+ export function literalType(value) {
74
+ if (typeof value === 'string')
75
+ return 'string';
76
+ if (typeof value === 'boolean')
77
+ return 'boolean';
78
+ if (typeof value === 'number' && Number.isInteger(value))
79
+ return 'integer';
80
+ return null;
81
+ }
82
+ /** Can a literal be compared with an attribute of this type? An identity is a string. */
83
+ export function literalFits(attributeType, value) {
84
+ const type = literalType(value);
85
+ if (type === null)
86
+ return false;
87
+ return attributeType === 'identity' ? type === 'string' : attributeType === type;
88
+ }
89
+ /** A value a comparison can use: a string, a boolean, or a whole number (not NaN, ±Infinity or 2.5). */
90
+ function isComparable(value) {
91
+ return typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isInteger(value));
92
+ }
93
+ /**
94
+ * Does a value read for an attribute fit the type it is declared with? A
95
+ * value that does not is treated as missing by the reader, so a comparison on
96
+ * it is unknown and fails closed, not a definite false or true (#2261). Never
97
+ * coerces: "5" is not an integer. An empty string is a string, a value and not
98
+ * a missing one.
99
+ */
100
+ export function valueFits(attributeType, value) {
101
+ switch (attributeType) {
102
+ case 'integer':
103
+ return typeof value === 'number' && Number.isInteger(value);
104
+ case 'boolean':
105
+ return typeof value === 'boolean';
106
+ case 'list':
107
+ return Array.isArray(value) && value.every(isComparable);
108
+ default: // string, identity
109
+ return typeof value === 'string';
110
+ }
111
+ }
112
+ /**
113
+ * Decides one comparison once both sides are read. `undefined` is unknown: a
114
+ * side that is missing, or not of a type the function can compare. Both sides
115
+ * must be the same kind of value (a string, a boolean or a whole number), so
116
+ * "5" never equals 5 and NaN never compares. `is-in` takes an array, or a Set the
117
+ * evaluator built from a literal list; every entry of an array must be the
118
+ * kind of value on the left. An empty string is a value, not a missing one.
119
+ */
120
+ export function compareLeaf(kind, left, right) {
121
+ if (!isComparable(left))
122
+ return undefined;
123
+ switch (kind) {
124
+ case 'equal':
125
+ return isComparable(right) && typeof right === typeof left ? left === right : undefined;
126
+ case 'is-in':
127
+ if (right instanceof Set)
128
+ return right.has(left);
129
+ return Array.isArray(right) && right.every((v) => isComparable(v) && typeof v === typeof left) ? right.includes(left) : undefined;
130
+ case 'greater-than':
131
+ return typeof left === 'number' && typeof right === 'number' && Number.isInteger(right) ? left > right : undefined;
132
+ case 'greater-than-or-equal':
133
+ return typeof left === 'number' && typeof right === 'number' && Number.isInteger(right) ? left >= right : undefined;
134
+ case 'less-than':
135
+ return typeof left === 'number' && typeof right === 'number' && Number.isInteger(right) ? left < right : undefined;
136
+ case 'less-than-or-equal':
137
+ return typeof left === 'number' && typeof right === 'number' && Number.isInteger(right) ? left <= right : undefined;
138
+ default:
139
+ return undefined;
140
+ }
141
+ }
142
+ /** The key that names how rules are combined (#2330). */
143
+ export const RULE_COMBINING_ALGORITHM_KEY = 'ngdpbase.access.rule-combining-algorithm';
144
+ /** The only algorithm the evaluator implements; XACML's name for it. */
145
+ export const SUPPORTED_COMBINING_ALGORITHMS = ['deny-overrides'];
146
+ /** What Admin → Configuration says about the algorithm. */
147
+ export const DENY_OVERRIDES_EXPLANATION = 'Any matching deny wins. Order and priority don\'t matter.';
148
+ /** What is wrong with the value of `ngdpbase.access.rule-combining-algorithm`, or `null`. Used on save, on load and at boot. */
149
+ export function combiningAlgorithmProblem(value) {
150
+ if (typeof value === 'string' && SUPPORTED_COMBINING_ALGORITHMS.includes(value))
151
+ return null;
152
+ return `${RULE_COMBINING_ALGORITHM_KEY} is ${JSON.stringify(value) ?? 'not set'}; the only value accepted is ${SUPPORTED_COMBINING_ALGORITHMS.map((a) => `"${a}"`).join(', ')}`;
153
+ }
154
+ /** `value` as an assurance level, or `undefined` when it is not 1, 2 or 3. Never coerces. */
155
+ export function toAal(value) {
156
+ return value === 1 || value === 2 || value === 3 ? value : undefined;
157
+ }
158
+ /**
159
+ * The subject's `subject:aal`, or `undefined` when it has none (#2261).
160
+ *
161
+ * - Not signed in, or a share visitor: none.
162
+ * - An agent token: the level stored with the token. A token made before this
163
+ * stored one has none.
164
+ * - A background job: the level stored with the job's request. A job with
165
+ * none (a schedule, boot, a request from before this) has none.
166
+ * - A bearer JWT that records no level (Authentik): none. It is not a session
167
+ * from before sign-ins were recorded, so the AAL 1 rule below does not apply.
168
+ * - A session: its sign-in record's level; a session from before sign-ins
169
+ * were recorded (#1523) is AAL 1, as `AuthManager` treats it.
170
+ *
171
+ * Capped at `aalCap`, as a role is. Reads the record; it never works the level
172
+ * out from factors.
173
+ */
174
+ export function effectiveAal(subject) {
175
+ if (!subject?.isAuthenticated || subject.viaShare || subject.bearerWithoutLevel)
176
+ return undefined;
177
+ let raw;
178
+ if (subject.viaToken)
179
+ raw = toAal(subject.viaToken.aal);
180
+ else if (subject.resolveRolesNow)
181
+ raw = toAal(subject.grantedAal);
182
+ else
183
+ raw = toAal(subject.signIn?.aal) ?? (subject.signIn ? undefined : 1);
184
+ if (raw === undefined)
185
+ return undefined;
186
+ return toAal(Math.min(raw, typeof subject.aalCap === 'number' && subject.aalCap >= 1 ? subject.aalCap : 3));
187
+ }
188
+ /**
189
+ * The fields of a request's subject that bear on its level, copied by name so
190
+ * a subject rebuilt for a decision keeps them (the #1164 lesson: a field not
191
+ * forwarded is silently lost). Reads nothing else from it.
192
+ */
193
+ export function aalFactsOf(subject) {
194
+ const s = (subject ?? {});
195
+ return {
196
+ signIn: s.signIn,
197
+ aalCap: typeof s.aalCap === 'number' ? s.aalCap : undefined,
198
+ viaToken: s.viaToken,
199
+ viaShare: s.viaShare,
200
+ bearerWithoutLevel: s.bearerWithoutLevel,
201
+ grantedAal: s.grantedAal,
202
+ resolveRolesNow: s.resolveRolesNow
203
+ };
204
+ }
205
+ /** Where a subject attribute may be read from: `account:<field>`, `profile:<field>` or `sign-in`. */
206
+ export const SUBJECT_SOURCES = ['account:', 'profile:', 'sign-in'];
207
+ /**
208
+ * `id` and `aal` mean what they mean here, whatever the configuration (or an
209
+ * add-on's default-config.json) says (#2287). `aal` is read from the sign-in
210
+ * record and `id` from the account; a site that could redefine either could
211
+ * make `subject:aal` read a field it controls and pass a "second factor" rule.
212
+ */
213
+ export const PINNED_SUBJECT_ATTRIBUTES = {
214
+ id: { type: 'identity', from: 'account:id' },
215
+ aal: { type: 'integer', from: 'sign-in' }
216
+ };
217
+ /** The subject attributes a site declares, with `id` and `aal` pinned to their built-in meaning. Anything not an object is ignored. */
218
+ export function declaredSubjectAttributes(raw) {
219
+ const declared = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
220
+ return { ...declared, ...PINNED_SUBJECT_ATTRIBUTES };
221
+ }
222
+ /**
223
+ * What is wrong with the value of `ngdpbase.access.subject-attributes` itself
224
+ * (#2287), one message each: not an object; `id` or `aal` declared with any
225
+ * meaning but the built-in one (the built-in is kept regardless; the same
226
+ * declaration as the built-in is harmless and not reported); and any other
227
+ * declaration `subjectDeclarationProblem` refuses. Used on save, on load and at boot.
228
+ */
229
+ export function subjectAttributesProblems(raw, sensitive) {
230
+ return subjectAttributesFindings(raw, sensitive).map((f) => f.log);
231
+ }
232
+ /** {@link subjectAttributesProblems}, each also in plain words for the needs-attention entry. */
233
+ export function subjectAttributesFindings(raw, sensitive) {
234
+ if (raw === null || raw === undefined)
235
+ return [];
236
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
237
+ return [{
238
+ log: 'ngdpbase.access.subject-attributes must be an object of attribute name to { type, from }',
239
+ plain: 'The list of user details that rules can check is not in a form the site can read, so none of them work. Fix or clear that list in Admin → Configuration.'
240
+ }];
241
+ }
242
+ return Object.entries(raw).flatMap(([name, decl]) => {
243
+ const pinned = Object.hasOwn(PINNED_SUBJECT_ATTRIBUTES, name) ? PINNED_SUBJECT_ATTRIBUTES[name] : undefined;
244
+ if (pinned) {
245
+ const same = !!decl && typeof decl === 'object' && decl.type === pinned.type && decl.from === pinned.from;
246
+ return same ? [] : [{
247
+ log: `subject attribute '${name}' in ngdpbase.access.subject-attributes cannot be redefined: it is built in (type ${pinned.type}, from ${pinned.from}) and keeps that meaning; remove it`,
248
+ plain: `The user detail '${name}' is built into the site and cannot be redefined, so your version is ignored. Remove it from the list of user details in Admin → Configuration.`
249
+ }];
250
+ }
251
+ const problem = subjectDeclarationFinding(name, decl, sensitive);
252
+ return problem ? [problem] : [];
253
+ });
254
+ }
255
+ /**
256
+ * What is wrong with a subject attribute's declaration, or `null`: a `type`
257
+ * that is not known, a `from` that is not a subject source, or an account field
258
+ * that must never be read (`sensitive`, the password, email, roles and the
259
+ * like). Said so a condition naming it is refused rather than silently unknown.
260
+ */
261
+ export function subjectDeclarationProblem(name, decl, sensitive) {
262
+ return subjectDeclarationFinding(name, decl, sensitive)?.log ?? null;
263
+ }
264
+ /** {@link subjectDeclarationProblem}, also in plain words. */
265
+ function subjectDeclarationFinding(name, decl, sensitive) {
266
+ const where = `subject attribute '${name}' in ngdpbase.access.subject-attributes`;
267
+ const plain = `The user detail '${name}' is not set up correctly, so a rule that checks it will not work. Fix it, or remove it, in the list of user details in Admin → Configuration.`;
268
+ if (!decl || typeof decl !== 'object')
269
+ return { log: `${where} must be an object with a type and a from`, plain };
270
+ if (!ATTRIBUTE_TYPES.includes(decl.type))
271
+ return { log: `${where} has type ${JSON.stringify(decl.type)}; use one of ${ATTRIBUTE_TYPES.join(', ')}`, plain };
272
+ const from = decl.from;
273
+ const source = typeof from === 'string' ? SUBJECT_SOURCES.find((s) => from === s || (s !== 'sign-in' && from.startsWith(s))) : undefined;
274
+ if (!source)
275
+ return { log: `${where} reads from ${JSON.stringify(from)}; start it with ${SUBJECT_SOURCES.join(', ')}`, plain };
276
+ if (source !== 'sign-in') {
277
+ const field = from.slice(source.length);
278
+ if (!field)
279
+ return { log: `${where} reads from '${source}' with no field; write ${source}<field>`, plain };
280
+ if (sensitive.includes(field)) {
281
+ return {
282
+ log: `'${source}${field}' cannot be used in a condition`,
283
+ plain: `The user detail '${name}' reads private account data, which a rule may not check. Remove it from the list of user details in Admin → Configuration.`
284
+ };
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+ /**
290
+ * A declaration map as written, every `$NAME` left unresolved, or `null` when
291
+ * it cannot be read. Declarations are read this way so a `$NAME` in one is
292
+ * judged and reported as written and an environment variable's value never
293
+ * reaches a message or a log line; a throwing read (an unset variable) is
294
+ * `null`, not an exception.
295
+ */
296
+ export function readDeclaredAsWritten(config, key) {
297
+ if (!config)
298
+ return null;
299
+ try {
300
+ // No unresolved read means no way to avoid resolving `$NAME`, so read nothing.
301
+ return typeof config.getUnresolvedProperty === 'function' ? config.getUnresolvedProperty(key, null) : null;
302
+ }
303
+ catch {
304
+ return null;
305
+ }
306
+ }
307
+ /** Where an environment attribute may be read from: `schedule:<name>` or `config:<key>` (#2261 slice 4). */
308
+ export const ENVIRONMENT_SOURCES = ['schedule:', 'config:'];
309
+ /** The environment attributes a site declares. Anything not an object is ignored. */
310
+ export function declaredEnvironmentAttributes(raw) {
311
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
312
+ }
313
+ /**
314
+ * What is wrong with an environment attribute's declaration, or `null`: a
315
+ * `type` that is not `boolean` (both sources yield a boolean and nothing
316
+ * else), a `from` that is not `schedule:<name>` or `config:<key>`, or one with
317
+ * no name or key after the prefix. Any configuration key may be read, secrets
318
+ * included (#2261): a condition can only compare the value, and the value is
319
+ * never written to a log, an audit record or a message.
320
+ */
321
+ export function environmentDeclarationProblem(name, decl) {
322
+ return environmentDeclarationFinding(name, decl)?.log ?? null;
323
+ }
324
+ /** {@link environmentDeclarationProblem}, also in plain words. */
325
+ function environmentDeclarationFinding(name, decl) {
326
+ const where = `environment attribute '${name}' in ngdpbase.access.environment-attributes`;
327
+ const plain = `The check '${name}' (for rules that depend on a schedule or a setting) is not set up correctly, so a rule that uses it will not work. Fix it, or remove it, in the list of checks in Admin → Configuration.`;
328
+ if (!decl || typeof decl !== 'object')
329
+ return { log: `${where} must be an object with a type and a from`, plain };
330
+ if (decl.type !== 'boolean')
331
+ return { log: `${where} has type ${JSON.stringify(decl.type)}; schedule: and config: attributes are boolean only, so use "boolean"`, plain };
332
+ const from = decl.from;
333
+ const source = typeof from === 'string' ? ENVIRONMENT_SOURCES.find((s) => from.startsWith(s)) : undefined;
334
+ if (!source)
335
+ return { log: `${where} reads from ${JSON.stringify(from)}; start it with ${ENVIRONMENT_SOURCES.join(' or ')}`, plain };
336
+ if (!from.slice(source.length))
337
+ return { log: `${where} reads from '${source}' with no ${source === 'schedule:' ? 'schedule name' : 'key'}; write ${source}<${source === 'schedule:' ? 'name' : 'key'}>`, plain };
338
+ return null;
339
+ }
340
+ /**
341
+ * What is wrong with the value of `ngdpbase.access.environment-attributes`
342
+ * itself, one message each: not an object, or a declaration
343
+ * {@link environmentDeclarationProblem} refuses. Used on save, on load and at boot.
344
+ */
345
+ export function environmentAttributesProblems(raw) {
346
+ return environmentAttributesFindings(raw).map((f) => f.log);
347
+ }
348
+ /** {@link environmentAttributesProblems}, each also in plain words for the needs-attention entry. */
349
+ export function environmentAttributesFindings(raw) {
350
+ if (raw === null || raw === undefined)
351
+ return [];
352
+ if (typeof raw !== 'object' || Array.isArray(raw)) {
353
+ return [{
354
+ log: 'ngdpbase.access.environment-attributes must be an object of attribute name to { type, from }',
355
+ plain: 'The list of checks that depend on a schedule or a setting is not in a form the site can read, so none of them work. Fix or clear that list in Admin → Configuration.'
356
+ }];
357
+ }
358
+ return Object.entries(raw).flatMap(([name, decl]) => {
359
+ const problem = environmentDeclarationFinding(name, decl);
360
+ return problem ? [problem] : [];
361
+ });
362
+ }
363
+ /**
364
+ * What the old plural `conditions` array's entry types become in the single `condition` (#2334).
365
+ * Only a user-attribute entry with an equality operator (`==` or `===`) has an equivalent: a string comparison.
366
+ */
367
+ const CONDITIONS_DECLARATION = '{ "department": { "type": "string", "from": "profile:department" } }';
368
+ const CONDITIONS_EXAMPLE = '{ "string-equal": [ { "attributeid": "subject:department", "datatype": "string" }, { "value": "IT", "datatype": "string" } ] }';
369
+ /** The policies that carry a non-empty old plural `conditions` array, named by id or by position (#2334). An empty array limits nothing either way, so it is not named. */
370
+ function policiesWithOldConditions(policies) {
371
+ if (!Array.isArray(policies))
372
+ return [];
373
+ return [...policies.entries()]
374
+ .filter(([, p]) => {
375
+ const c = p?.conditions;
376
+ return Array.isArray(c) && c.length > 0;
377
+ })
378
+ .map(([i, p]) => `'${typeof p.id === 'string' ? p.id : `#${i + 1}`}'`);
379
+ }
380
+ /**
381
+ * The words an administrator sees (the needs-attention entry and the save message) when a policy carries
382
+ * the old plural `conditions` array, or null when none does (#2334). Plain words: no issue number, no JSON.
383
+ */
384
+ export function unenforcedConditionsNotice(policies) {
385
+ const names = policiesWithOldConditions(policies);
386
+ if (names.length === 0)
387
+ return null;
388
+ const one = names.length === 1;
389
+ return `${one ? 'This rule has' : 'These rules have'} an old 'conditions' list that the site ignores, so it doesn't limit anyone: ${names.join(', ')}. Move ${one ? 'it' : 'each one'} into ${one ? 'the' : 'its'} rule's condition, or remove it.`;
390
+ }
391
+ /**
392
+ * The start-up log line for the same policies, in full: the condition to write, the attribute
393
+ * declaration it needs, and the entry types with no equivalent (#2334). Null when none has one.
394
+ * Nothing in the decision path reads that array, so a rule written there limits no access.
395
+ */
396
+ export function unenforcedConditionsLine(policies) {
397
+ const names = policiesWithOldConditions(policies);
398
+ if (names.length === 0)
399
+ return null;
400
+ return `${names.join(', ')} ${names.length === 1 ? 'has' : 'have'} a conditions array, which is not enforced: nothing reads it, so it limits no access. `
401
+ + `Replace it with the single condition in the XACML form. A user-attribute entry with the == or === operator becomes ${CONDITIONS_EXAMPLE}, `
402
+ + `which needs the attribute declared with a source in ngdpbase.access.subject-attributes, for example ${CONDITIONS_DECLARATION} (see docs/access-policies.md#subject-attributes). `
403
+ + 'The other entry types (time-range, ip-range, context-attribute, session-attribute, environment, and user-attribute with any other operator) have no equivalent: a rule you need cannot be written yet, so do not rely on it (#2334)';
404
+ }
@@ -3,8 +3,10 @@
3
3
  * every vault below it (docs/private-stores.md, Access).
4
4
  *
5
5
  * The owner, a share link the owner issued, or someone acting for the owner
6
- * under a proxy grant that carries `vault-proxy` (#1762). No role reaches in,
7
- * admin included.
6
+ * under a proxy grant that carries `vault-proxy` (#1762). No role reaches in
7
+ * through this rule. At a door, an administrator may also enter a vault that
8
+ * is not encrypted when `ngdpbase.vaults.admin-access` is true (#2255,
9
+ * {@link mayEnterVaultAtDoor}), and each such access is recorded.
8
10
  *
9
11
  * A share link gets in only for a vault it names (#1388), and only when the
10
12
  * caller names that vault: a question about the owner's container as a whole
@@ -25,8 +27,12 @@
25
27
  * slice 4).
26
28
  */
27
29
  import { type ActorContext } from '../context/ActorContext.js';
30
+ /** The administrator's way into a vault (#2255): `PolicyDecisionPoint.adminMayEnterVault`. */
31
+ interface AdminVaultEntry {
32
+ adminMayEnterVault?(ctx: unknown, owner: string, vault: string, resource: string, record?: boolean): Promise<boolean>;
33
+ }
28
34
  /** The PDP surface a vault decision needs. */
29
- export interface VaultDecider {
35
+ export interface VaultDecider extends AdminVaultEntry {
30
36
  decide(subject: unknown, request: {
31
37
  action: string;
32
38
  resource: {
@@ -36,10 +42,12 @@ export interface VaultDecider {
36
42
  attributes: Record<string, unknown>;
37
43
  }): Promise<{
38
44
  permit: boolean;
45
+ policyIds?: string[];
46
+ conditionNotMet?: string[];
39
47
  }>;
40
48
  }
41
49
  /** The PDP surface {@link holdsInVault} needs. */
42
- export interface VaultHolder {
50
+ export interface VaultHolder extends AdminVaultEntry {
43
51
  holds(subject: unknown, action: string, on: {
44
52
  resource: {
45
53
  type: string;
@@ -48,6 +56,17 @@ export interface VaultHolder {
48
56
  attributes: Record<string, unknown>;
49
57
  }): Promise<boolean>;
50
58
  }
59
+ /**
60
+ * The container rule at a door (#2255): the owner or their delegate
61
+ * ({@link mayActInPrivateContainer}), or else an administrator let in by
62
+ * `ngdpbase.vaults.admin-access` to a vault that is not encrypted, recorded
63
+ * when `record`. Lists never ask this; they keep the owner's rule alone.
64
+ */
65
+ export declare function mayEnterVaultAtDoor(pdp: AdminVaultEntry | null | undefined, ctx: ActorContext, where: {
66
+ owner: string;
67
+ vault: string;
68
+ resource: string;
69
+ }, record?: boolean): Promise<boolean>;
51
70
  export declare function mayActInPrivateContainer(ctx: ActorContext, owner: string, opts?: {
52
71
  vault?: string;
53
72
  }): boolean;
@@ -80,3 +99,4 @@ export declare function holdsInVault(pdp: VaultHolder | null | undefined, ctx: A
80
99
  vault: string;
81
100
  resource: string;
82
101
  }): Promise<boolean>;
102
+ export {};
@@ -3,8 +3,10 @@
3
3
  * every vault below it (docs/private-stores.md, Access).
4
4
  *
5
5
  * The owner, a share link the owner issued, or someone acting for the owner
6
- * under a proxy grant that carries `vault-proxy` (#1762). No role reaches in,
7
- * admin included.
6
+ * under a proxy grant that carries `vault-proxy` (#1762). No role reaches in
7
+ * through this rule. At a door, an administrator may also enter a vault that
8
+ * is not encrypted when `ngdpbase.vaults.admin-access` is true (#2255,
9
+ * {@link mayEnterVaultAtDoor}), and each such access is recorded.
8
10
  *
9
11
  * A share link gets in only for a vault it names (#1388), and only when the
10
12
  * caller names that vault: a question about the owner's container as a whole
@@ -29,6 +31,19 @@ import { shareNamesVault } from '../types/Share.js';
29
31
  import { mixesProxyWithDelegation, proxyCarries } from './proxyClaim.js';
30
32
  import { isSharedFolderOwner } from './privateStorePath.js';
31
33
  import { permitsInSharedFolder } from './sharedFolderAccess.js';
34
+ /**
35
+ * The container rule at a door (#2255): the owner or their delegate
36
+ * ({@link mayActInPrivateContainer}), or else an administrator let in by
37
+ * `ngdpbase.vaults.admin-access` to a vault that is not encrypted, recorded
38
+ * when `record`. Lists never ask this; they keep the owner's rule alone.
39
+ */
40
+ export async function mayEnterVaultAtDoor(pdp, ctx, where, record = true) {
41
+ if (mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
42
+ return true;
43
+ if (isSharedFolderOwner(where.owner) || typeof pdp?.adminMayEnterVault !== 'function')
44
+ return false;
45
+ return pdp.adminMayEnterVault(ctx, where.owner, where.vault, where.resource, record);
46
+ }
32
47
  export function mayActInPrivateContainer(ctx, owner, opts = {}) {
33
48
  // #2125: the shared folders' container has no owner who acts; a folder is
34
49
  // decided by permission (`permitsInSharedFolder`), never by this rule.
@@ -67,7 +82,7 @@ export function mayActInPrivateContainer(ctx, owner, opts = {}) {
67
82
  export async function permitsInVault(pdp, ctx, action, where) {
68
83
  if (isSharedFolderOwner(where.owner))
69
84
  return permitsInSharedFolder(pdp, ctx, action, where.vault);
70
- if (!pdp || !mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
85
+ if (!pdp || !(await mayEnterVaultAtDoor(pdp, ctx, where)))
71
86
  return false;
72
87
  return (await pdp.decide(ctx, { action, resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } })).permit;
73
88
  }
@@ -78,7 +93,7 @@ export async function permitsInVault(pdp, ctx, action, where) {
78
93
  * folders are not asked: `permitsInVault` decides them by permission alone.
79
94
  */
80
95
  export async function holdsInVault(pdp, ctx, action, where) {
81
- if (isSharedFolderOwner(where.owner) || typeof pdp?.holds !== 'function' || !mayActInPrivateContainer(ctx, where.owner, { vault: where.vault }))
96
+ if (isSharedFolderOwner(where.owner) || typeof pdp?.holds !== 'function' || !(await mayEnterVaultAtDoor(pdp, ctx, where, false)))
82
97
  return false;
83
98
  return pdp.holds(ctx, action, { resource: { type: 'page', id: where.resource }, attributes: { vault: where.vault } });
84
99
  }
@@ -26,7 +26,7 @@ export interface RoleGrants {
26
26
  }
27
27
  /** A grant to one subject, with the policies that give it (#2067). */
28
28
  export interface SubjectGrant extends RoleGrant {
29
- /** The ids of the allow policies that give it, lowest priority first. */
29
+ /** The ids of the allow policies that give it, in id order. */
30
30
  by: string[];
31
31
  }
32
32
  export interface SubjectGrants {
@@ -64,6 +64,17 @@ export declare const ACCOUNTLESS_PERSON_PREFIX = "person:";
64
64
  * anything else a username not yet converted.
65
65
  */
66
66
  export declare const IDENTITY_ID_PREFIX = "urn:";
67
+ /**
68
+ * The prefix of an add-on's pages' author (#2255): `addon:<slug>`. The pages
69
+ * an add-on pushes to a site are authored by the add-on, as required pages
70
+ * are by `system`. No username may start with it, so no account is ever an
71
+ * add-on page's author, and a start-up conversion keeps it as it is.
72
+ */
73
+ export declare const ADDON_AUTHOR_PREFIX = "addon:";
74
+ /** The author of the pages add-on `slug` pushes (#2255). */
75
+ export declare function addonAuthor(slug: string): string;
76
+ /** True when `value` names an add-on as a page's author (#2255). */
77
+ export declare function isAddonAuthor(value: unknown): boolean;
67
78
  /**
68
79
  * The prefix a start-up conversion gives a stored username that has no
69
80
  * account (#2041): `orphan:<name>`. The record is then nobody's: a later
@@ -86,7 +97,7 @@ export declare function isNoRecordedUser(value: unknown): boolean;
86
97
  /**
87
98
  * True when the name belongs to a principal rather than a user, is a "no user
88
99
  * recorded" value ({@link NO_RECORDED_USER}, #2097: an account by that name
89
- * would take over the records that hold it), or has the contact, `@id` or
90
- * orphan prefix.
100
+ * would take over the records that hold it), or has the contact, `@id`, orphan or
101
+ * add-on author (#2255) prefix.
91
102
  */
92
103
  export declare function isReservedUsername(username: unknown): boolean;
@@ -71,6 +71,21 @@ export const ACCOUNTLESS_PERSON_PREFIX = 'person:';
71
71
  * anything else a username not yet converted.
72
72
  */
73
73
  export const IDENTITY_ID_PREFIX = 'urn:';
74
+ /**
75
+ * The prefix of an add-on's pages' author (#2255): `addon:<slug>`. The pages
76
+ * an add-on pushes to a site are authored by the add-on, as required pages
77
+ * are by `system`. No username may start with it, so no account is ever an
78
+ * add-on page's author, and a start-up conversion keeps it as it is.
79
+ */
80
+ export const ADDON_AUTHOR_PREFIX = 'addon:';
81
+ /** The author of the pages add-on `slug` pushes (#2255). */
82
+ export function addonAuthor(slug) {
83
+ return `${ADDON_AUTHOR_PREFIX}${slug}`;
84
+ }
85
+ /** True when `value` names an add-on as a page's author (#2255). */
86
+ export function isAddonAuthor(value) {
87
+ return typeof value === 'string' && value.length > ADDON_AUTHOR_PREFIX.length && value.startsWith(ADDON_AUTHOR_PREFIX);
88
+ }
74
89
  /**
75
90
  * The prefix a start-up conversion gives a stored username that has no
76
91
  * account (#2041): `orphan:<name>`. The record is then nobody's: a later
@@ -96,10 +111,10 @@ export function isNoRecordedUser(value) {
96
111
  /**
97
112
  * True when the name belongs to a principal rather than a user, is a "no user
98
113
  * recorded" value ({@link NO_RECORDED_USER}, #2097: an account by that name
99
- * would take over the records that hold it), or has the contact, `@id` or
100
- * orphan prefix.
114
+ * would take over the records that hold it), or has the contact, `@id`, orphan or
115
+ * add-on author (#2255) prefix.
101
116
  */
102
117
  export function isReservedUsername(username) {
103
118
  const name = normalizeUsername(username);
104
- return RESERVED_USERNAMES.includes(name) || NO_RECORDED_USER.includes(name) || name.startsWith(ACCOUNTLESS_PERSON_PREFIX) || name.startsWith(IDENTITY_ID_PREFIX) || name.startsWith(ORPHAN_PREFIX);
119
+ return RESERVED_USERNAMES.includes(name) || NO_RECORDED_USER.includes(name) || name.startsWith(ACCOUNTLESS_PERSON_PREFIX) || name.startsWith(IDENTITY_ID_PREFIX) || name.startsWith(ORPHAN_PREFIX) || name.startsWith(ADDON_AUTHOR_PREFIX);
105
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jwilleke/ngdpbase-types",
3
- "version": "5.42.0",
3
+ "version": "5.44.0",
4
4
  "description": "TypeScript types for ngdpbase add-ons, and a test kit of core's own values. A dev dependency, versioned with ngdpbase.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Jim Willeke",
@@ -45,20 +45,20 @@
45
45
  "dotenv": "^18.0.9",
46
46
  "express": "^5.3.0",
47
47
  "express-session": "^1.19.0",
48
- "fast-xml-parser": "^5.11.2",
48
+ "fast-xml-parser": "^5.12.0",
49
49
  "fs-extra": "^11.4.1",
50
50
  "ipaddr.js": "^2.5.0",
51
- "jose": "^6.2.14",
52
- "linkedom": "^0.18.14",
51
+ "jose": "^6.2.15",
52
+ "linkedom": "^0.18.15",
53
53
  "markdown-it": "^14.3.3",
54
54
  "minisearch": "7.2.0",
55
- "mmdb-lib": "^3.0.3",
55
+ "mmdb-lib": "^3.0.4",
56
56
  "multer": "^2.4.0",
57
- "otpauth": "^9.5.2",
57
+ "otpauth": "^9.6.0",
58
58
  "qrcode": "^1.5.4",
59
59
  "rrule-temporal": "2.2.8",
60
60
  "tar": "^7.5.22",
61
- "temporal-polyfill": "1.0.6",
61
+ "temporal-polyfill": "1.0.8",
62
62
  "winston": "^3.17.0"
63
63
  }
64
64
  }