@jwilleke/ngdpbase-types 5.43.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.
- package/lib/src/context/ApiContext.d.ts +10 -3
- package/lib/src/context/ApiContext.js +36 -3
- package/lib/src/ice/runExport.d.ts +8 -3
- package/lib/src/ice/searchExportSource.d.ts +44 -0
- package/lib/src/ice/types.d.ts +6 -0
- package/lib/src/managers/AttachmentManager.d.ts +11 -0
- package/lib/src/managers/AuditManager.d.ts +0 -1
- package/lib/src/managers/AuthManager.d.ts +38 -0
- package/lib/src/managers/AuthManager.js +84 -12
- package/lib/src/managers/BackupManager.d.ts +9 -2
- package/lib/src/managers/BackupManager.js +42 -19
- package/lib/src/managers/BaseManager.d.ts +17 -3
- package/lib/src/managers/BaseManager.js +12 -8
- package/lib/src/managers/ConfigurationManager.d.ts +13 -0
- package/lib/src/managers/ConsentManager.d.ts +1 -1
- package/lib/src/managers/DatabaseManager.js +17 -8
- package/lib/src/managers/EmailManager.d.ts +1 -1
- package/lib/src/managers/ExportManager.d.ts +1 -44
- package/lib/src/managers/ExportManager.js +3 -115
- package/lib/src/managers/ImportManager.d.ts +7 -0
- package/lib/src/managers/PageManager.d.ts +9 -0
- package/lib/src/managers/PolicyEvaluator.d.ts +113 -30
- package/lib/src/managers/ThemeManager.d.ts +20 -1
- package/lib/src/providers/FileLoggingProvider.d.ts +12 -1
- package/lib/src/providers/FileLoggingProvider.js +37 -4
- package/lib/src/security/PolicyDecisionPoint.d.ts +50 -3
- package/lib/src/security/PolicyInformationPoint.d.ts +27 -1
- package/lib/src/security/ownedRecords.d.ts +69 -0
- package/lib/src/security/signedOutWrites.d.ts +69 -0
- package/lib/src/security/signedOutWrites.js +146 -0
- package/lib/src/types/Policy.d.ts +26 -30
- package/lib/src/utils/addonConfigLayer.d.ts +6 -3
- package/lib/src/utils/addonConfigLayer.js +61 -3
- package/lib/src/utils/addonHost.d.ts +25 -0
- package/lib/src/utils/addonHost.js +29 -1
- package/lib/src/utils/availability.d.ts +15 -0
- package/lib/src/utils/availability.js +88 -0
- package/lib/src/utils/pageTitleConflict.d.ts +32 -0
- package/lib/src/utils/pageWritePolicies.d.ts +48 -29
- package/lib/src/utils/plainAttention.d.ts +19 -0
- package/lib/src/utils/plainAttention.js +18 -0
- package/lib/src/utils/policyAttributes.d.ts +113 -22
- package/lib/src/utils/policyAttributes.js +228 -43
- package/lib/src/utils/roleGrants.d.ts +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed-out write list (#2298): the one place that names which routes may
|
|
3
|
+
* be written to by someone who is not signed in.
|
|
4
|
+
*
|
|
5
|
+
* Every request from a caller who is not signed in, other than GET, HEAD and
|
|
6
|
+
* OPTIONS, is refused by `signedOutWriteGate` (src/middleware/signedOutWriteGate.ts),
|
|
7
|
+
* before any route runs, unless the route is on this list. A new write route
|
|
8
|
+
* is therefore closed to signed-out callers until someone adds it here with a
|
|
9
|
+
* reason, which is the point (#2294 came from routes that were open until
|
|
10
|
+
* their author remembered to close them).
|
|
11
|
+
*
|
|
12
|
+
* What belongs here: a route a person reaches BEFORE they are signed in (the
|
|
13
|
+
* steps of signing in, registering, recovering a password, accepting an
|
|
14
|
+
* invitation), a link followed from an email in any browser, and a public form.
|
|
15
|
+
* What does not: anything that asks for a permission a signed-out caller
|
|
16
|
+
* never holds. A route that answers its own token (a Bearer token, which the
|
|
17
|
+
* gate counts as signed in) needs no entry.
|
|
18
|
+
*
|
|
19
|
+
* Core's routes are listed below. An add-on lists its own through the add-on
|
|
20
|
+
* host, `host.allowSignedOutWrite(...)`, which accepts only a route under the
|
|
21
|
+
* add-on's own `/api/<slug>/` or `/addons/<slug>/`, and refuses a slug that
|
|
22
|
+
* names one of core's own `/api/` or `/addons/` prefixes. Those are the only
|
|
23
|
+
* ways onto the list, and it is sealed once the add-ons have loaded; outside
|
|
24
|
+
* tests (`NODE_ENV=test`) the seal cannot be undone.
|
|
25
|
+
*
|
|
26
|
+
* Permission to write is still asked by each route, as always: an entry only
|
|
27
|
+
* lets the request reach the route, it grants nothing.
|
|
28
|
+
*/
|
|
29
|
+
/** The methods that only read. Every other method is held by the gate, so a new one (PROPFIND, MERGE, SEARCH…) fails closed. */
|
|
30
|
+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
31
|
+
/** The methods an add-on may list. */
|
|
32
|
+
const LISTABLE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
33
|
+
/** Whether the method may change things, and so is held by the gate: anything but GET, HEAD and OPTIONS. */
|
|
34
|
+
export function isWriteMethod(method) {
|
|
35
|
+
return !SAFE_METHODS.has(method.toUpperCase());
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Slugs an add-on may not use to list a route, because core already answers
|
|
39
|
+
* under `/api/<slug>/` or `/addons/<slug>/`: an add-on in a directory named
|
|
40
|
+
* `admin` must not open `/api/admin/...`. Kept in step with core's route
|
|
41
|
+
* files; `signedOutWriteGate.test.ts` checks it against the route inventory.
|
|
42
|
+
*/
|
|
43
|
+
export const RESERVED_ADDON_SLUGS = new Set([
|
|
44
|
+
'addons', 'admin', 'agent', 'agents', 'assets', 'attachments', 'audit', 'auth', 'backup', 'catalog', 'check-updates',
|
|
45
|
+
'comments', 'config', 'consent', 'core', 'export', 'footnotes', 'import', 'install', 'keywords', 'login', 'logout',
|
|
46
|
+
'media', 'notifications', 'oidc', 'page', 'page-metadata', 'page-source', 'page-suggestions', 'pages', 'policies',
|
|
47
|
+
'policy', 'preview', 'profile', 'register', 'search', 'session', 'session-count', 'session-users', 'sessions', 'share',
|
|
48
|
+
'system', 'test', 'tokens', 'user', 'user-keywords', 'users', 'vault', 'wiki'
|
|
49
|
+
]);
|
|
50
|
+
/**
|
|
51
|
+
* Core's list. Each reason says why the route cannot wait for a sign-in.
|
|
52
|
+
* Routes that need a signed-in session by their nature (consent, the
|
|
53
|
+
* account-setup password, the second-factor enrolment, reauthentication) are
|
|
54
|
+
* not here: their callers are signed in when they reach them.
|
|
55
|
+
*/
|
|
56
|
+
export const CORE_SIGNED_OUT_WRITES = Object.freeze([
|
|
57
|
+
// Signing in. The caller has no session yet.
|
|
58
|
+
{ method: 'POST', path: '/login', reason: 'password step of signing in' },
|
|
59
|
+
{ method: 'POST', path: '/auth/magic-link', reason: 'asks for a sign-in link by email' },
|
|
60
|
+
{ method: 'POST', path: '/auth/magic-link/verify', reason: 'completes a sign-in from the emailed link' },
|
|
61
|
+
{ method: 'POST', path: '/auth/passkey/authenticate/verify', reason: 'signs in with a passkey' },
|
|
62
|
+
{ method: 'POST', path: '/auth/oauth/:name', reason: 'starts a sign-in with an outside provider' },
|
|
63
|
+
{ method: 'POST', path: '/auth/choose-account', reason: 'finishes an outside sign-in whose email is shared by several accounts' },
|
|
64
|
+
{ method: 'POST', path: '/login/connect/cancel', reason: 'drops an outside identity waiting to be connected, from the login page' },
|
|
65
|
+
// The second factor. It needs the pending sign-in handle from the password step, not a session.
|
|
66
|
+
{ method: 'POST', path: '/login/second-factor/email', reason: 'second factor: sends the approval email for the waiting sign-in' },
|
|
67
|
+
{ method: 'POST', path: '/login/second-factor/complete', reason: 'second factor: completes the waiting sign-in' },
|
|
68
|
+
{ method: 'POST', path: '/login/second-factor/cancel', reason: 'second factor: abandons the waiting sign-in' },
|
|
69
|
+
{ method: 'POST', path: '/login/second-factor/totp', reason: 'second factor: authenticator-app code for the waiting sign-in' },
|
|
70
|
+
{ method: 'POST', path: '/login/second-factor/device', reason: 'second factor: proof from a remembered browser for the waiting sign-in' },
|
|
71
|
+
{ method: 'POST', path: '/login/approve', reason: 'approves or refuses a waiting sign-in from the emailed link, opened in any browser' },
|
|
72
|
+
// Signing out. A session that expired still has to be able to end.
|
|
73
|
+
{ method: 'POST', path: '/logout', reason: 'ends the session; a signed-out caller is sent on to sign in' },
|
|
74
|
+
// Becoming a user, and getting back in.
|
|
75
|
+
{ method: 'POST', path: '/register', reason: 'registration (the handler answers 404 when it is off)' },
|
|
76
|
+
{ method: 'POST', path: '/recover-password', reason: 'password reset with the recovery words' },
|
|
77
|
+
{ method: 'POST', path: '/invitation', reason: 'accepts an invitation by its token, which signs the person in' },
|
|
78
|
+
{ method: 'POST', path: '/account/email/confirm', reason: 'confirms a changed email address from the emailed link, opened in any browser' },
|
|
79
|
+
// Public forms.
|
|
80
|
+
{ method: 'POST', path: '/contact', reason: 'the public contact form (the handler answers 404 when it is off)' },
|
|
81
|
+
// First run: no account exists to sign in with. The handlers refuse once the site is installed.
|
|
82
|
+
{ method: 'POST', path: '/install', reason: 'first-run installation, before any account exists' },
|
|
83
|
+
{ method: 'POST', path: '/install/reset', reason: 'first-run installation, before any account exists' }
|
|
84
|
+
]);
|
|
85
|
+
/** What a route path means to Express: `:name` is one segment; the match ignores case and a trailing slash. */
|
|
86
|
+
function matcherFor(path) {
|
|
87
|
+
const body = path
|
|
88
|
+
.split('/')
|
|
89
|
+
.map((segment) => (segment.startsWith(':') ? '[^/]+' : segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
|
90
|
+
.join('/');
|
|
91
|
+
return new RegExp(`^${body}/?$`, 'i');
|
|
92
|
+
}
|
|
93
|
+
const entryOf = (route, owner) => ({ ...route, owner, matches: matcherFor(route.path) });
|
|
94
|
+
const SEGMENT = /^(?:[A-Za-z0-9_~-][A-Za-z0-9._~-]*|:[A-Za-z][A-Za-z0-9_]*)$/;
|
|
95
|
+
let addonEntries = [];
|
|
96
|
+
let sealed = false;
|
|
97
|
+
const coreEntries = CORE_SIGNED_OUT_WRITES.map((r) => entryOf(r, 'core'));
|
|
98
|
+
/**
|
|
99
|
+
* An add-on's declaration of a route a signed-out caller may write to.
|
|
100
|
+
* Called through `AddonHost.allowSignedOutWrite`, bound to the add-on's slug.
|
|
101
|
+
* Throws on anything outside the add-on's own paths, so one add-on can never
|
|
102
|
+
* open another's, or core's.
|
|
103
|
+
*/
|
|
104
|
+
export function declareAddonSignedOutWrite(slug, route) {
|
|
105
|
+
const refuse = (why) => {
|
|
106
|
+
throw new Error(`Add-on '${slug}' cannot allow a signed-out ${String(route?.method)} ${String(route?.path)}: ${why}`);
|
|
107
|
+
};
|
|
108
|
+
if (sealed)
|
|
109
|
+
refuse('the list is sealed once add-ons have loaded; declare it in register()');
|
|
110
|
+
if (RESERVED_ADDON_SLUGS.has(slug.toLowerCase()))
|
|
111
|
+
refuse(`'${slug}' is one of core's own route prefixes`);
|
|
112
|
+
if (!route || typeof route.method !== 'string' || !LISTABLE_METHODS.has(route.method.toUpperCase()))
|
|
113
|
+
refuse('the method must be POST, PUT, PATCH or DELETE');
|
|
114
|
+
if (typeof route.reason !== 'string' || route.reason.trim() === '')
|
|
115
|
+
refuse('say why in `reason`');
|
|
116
|
+
if (typeof route.path !== 'string')
|
|
117
|
+
return refuse('the path must be a string');
|
|
118
|
+
const own = [`/api/${slug}/`, `/addons/${slug}/`];
|
|
119
|
+
if (!own.some((prefix) => route.path.startsWith(prefix)))
|
|
120
|
+
refuse(`the path must be under ${own.join(' or ')}`);
|
|
121
|
+
const segments = route.path.split('/').slice(1);
|
|
122
|
+
if (segments.some((s) => !SEGMENT.test(s)))
|
|
123
|
+
refuse('the path may hold only literal segments and :name parameters');
|
|
124
|
+
addonEntries.push(entryOf({ ...route, method: route.method.toUpperCase() }, slug));
|
|
125
|
+
}
|
|
126
|
+
/** Close the list to add-ons. Called once, after they have loaded. */
|
|
127
|
+
export function sealSignedOutWrites() {
|
|
128
|
+
sealed = true;
|
|
129
|
+
}
|
|
130
|
+
/** Test support: forget add-on declarations and reopen the list. Refuses outside `NODE_ENV=test`, so a sealed list stays sealed. */
|
|
131
|
+
export function resetSignedOutWrites() {
|
|
132
|
+
if (process.env.NODE_ENV !== 'test' && !process.env.VITEST)
|
|
133
|
+
throw new Error('resetSignedOutWrites is for tests only (NODE_ENV=test)');
|
|
134
|
+
addonEntries = [];
|
|
135
|
+
sealed = false;
|
|
136
|
+
}
|
|
137
|
+
/** Every route on the list, core's then the add-ons'. */
|
|
138
|
+
export function signedOutWriteRoutes() {
|
|
139
|
+
return [...coreEntries, ...addonEntries].map(({ method, path, reason, owner }) => ({ method, path, reason, owner }));
|
|
140
|
+
}
|
|
141
|
+
/** The reason the route is on the list, or null when a signed-out caller may not write to it. */
|
|
142
|
+
export function signedOutWriteReason(method, path) {
|
|
143
|
+
const m = method.toUpperCase();
|
|
144
|
+
const hit = [...coreEntries, ...addonEntries].find((e) => e.method === m && e.matches.test(path));
|
|
145
|
+
return hit ? hit.reason : null;
|
|
146
|
+
}
|
|
@@ -30,40 +30,38 @@ export interface PolicyResource {
|
|
|
30
30
|
pattern: string;
|
|
31
31
|
}
|
|
32
32
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* `
|
|
33
|
+
* An argument of a comparison (#2255, #2261, #2330), as XACML 3.0 writes one in
|
|
34
|
+
* lowercase. An attribute is `{ "attributeid": "subject:department-id",
|
|
35
|
+
* "datatype": "string" }`: `attributeid` starts `subject:`, `resource:` or
|
|
36
|
+
* `environment:`. A literal is `{ "value": "sales", "datatype": "string" }`,
|
|
37
|
+
* and for `X-is-in` its `value` may be an array. `datatype` is required and is
|
|
38
|
+
* one of the XACML JSON profile's short names.
|
|
37
39
|
*/
|
|
38
|
-
export type
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
equals: unknown;
|
|
40
|
+
export type ConditionOperand = {
|
|
41
|
+
attributeid: string;
|
|
42
|
+
datatype: string;
|
|
42
43
|
} | {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
} | {
|
|
47
|
-
'greater-or-equal': number | string;
|
|
48
|
-
} | {
|
|
49
|
-
'less-or-equal': number | string;
|
|
50
|
-
});
|
|
44
|
+
value: unknown;
|
|
45
|
+
datatype: string;
|
|
46
|
+
};
|
|
51
47
|
/**
|
|
52
|
-
* A policy's condition:
|
|
53
|
-
*
|
|
48
|
+
* A policy's condition (#2330): one function, named for its XACML 3.0 function
|
|
49
|
+
* in lowercase, whose value is the array of its arguments.
|
|
50
|
+
*
|
|
51
|
+
* - `string-equal`, `integer-equal`, `boolean-equal`, `string-is-in`,
|
|
52
|
+
* `integer-is-in`, `integer-greater-than`, `integer-greater-than-or-equal`,
|
|
53
|
+
* `integer-less-than`, `integer-less-than-or-equal` take two
|
|
54
|
+
* {@link ConditionOperand}s;
|
|
55
|
+
* - `and` and `or` take a list of conditions, `not` takes one;
|
|
54
56
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
57
|
+
* nested at most 4 deep.
|
|
58
|
+
*
|
|
59
|
+
* Each node is true, false or unknown. A comparison reading an attribute that
|
|
60
|
+
* is missing is unknown; in `or` a true branch decides, in `and` a false one
|
|
61
|
+
* does, and `not` of unknown is unknown. An allow applies only when the
|
|
58
62
|
* condition is true; a deny applies when it is true or unknown.
|
|
59
63
|
*/
|
|
60
|
-
export type PolicyCondition =
|
|
61
|
-
'any-of': PolicyCondition[];
|
|
62
|
-
} | {
|
|
63
|
-
'all-of': PolicyCondition[];
|
|
64
|
-
} | {
|
|
65
|
-
not: PolicyCondition;
|
|
66
|
-
};
|
|
64
|
+
export type PolicyCondition = Record<string, Array<ConditionOperand | PolicyCondition>>;
|
|
67
65
|
/** The prefixes that mark a condition value as an attribute rather than a literal (#2255). */
|
|
68
66
|
export declare const ATTRIBUTE_CATEGORIES: readonly ["subject", "resource", "environment"];
|
|
69
67
|
/** True when `value` names an attribute (`subject:id`, `resource:owner`), not a literal. */
|
|
@@ -73,8 +71,6 @@ export interface Policy {
|
|
|
73
71
|
id: string;
|
|
74
72
|
name?: string;
|
|
75
73
|
description?: string;
|
|
76
|
-
/** Lower runs first, so a higher-priority deny lands last and wins. */
|
|
77
|
-
priority?: number;
|
|
78
74
|
/**
|
|
79
75
|
* `allow` or `deny`. Typed as a string because the value comes from
|
|
80
76
|
* configuration, where an operator can write anything; the evaluator treats
|
|
@@ -128,10 +128,13 @@ export interface AddonJobPolicyLayer {
|
|
|
128
128
|
problems: string[];
|
|
129
129
|
}
|
|
130
130
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
131
|
+
* An add-on's resource types (#1823). Each name starts with `<slug>-`, the
|
|
132
|
+
* same fence as its jobs and its ledger tables. `owner`, when declared, is
|
|
133
|
+
* an identity @id. A bad declaration refuses the add-on, so none of its keys
|
|
134
|
+
* merge. A turned-off add-on is not checked: its file is not merged.
|
|
134
135
|
*/
|
|
136
|
+
export declare function addonResourceTypeProblems(slug: string, defaults: Record<string, unknown>): string[];
|
|
137
|
+
export declare function addonResourceTypeLayer(addons: readonly AddonDefaults[]): AddonJobPolicyLayer;
|
|
135
138
|
export declare function addonJobPolicyLayer(addons: readonly AddonDefaults[]): AddonJobPolicyLayer;
|
|
136
139
|
/** The configuration key that makes an addon site-critical (#1883). */
|
|
137
140
|
export declare function addonSiteCriticalKey(slug: string): string;
|
|
@@ -316,6 +316,63 @@ export function addonSensitiveValuesLayer(addons) {
|
|
|
316
316
|
* `<slug>.`, the prefix its job ids carry. Only an enabled addon's policies
|
|
317
317
|
* reach the merge, so only an enabled addon is checked.
|
|
318
318
|
*/
|
|
319
|
+
const RESOURCE_TYPES_KEY = 'ngdpbase.access.resource-types';
|
|
320
|
+
const ATTRIBUTE_TYPES = new Set(['string', 'integer', 'boolean', 'identity', 'list']);
|
|
321
|
+
/**
|
|
322
|
+
* An add-on's resource types (#1823). Each name starts with `<slug>-`, the
|
|
323
|
+
* same fence as its jobs and its ledger tables. `owner`, when declared, is
|
|
324
|
+
* an identity @id. A bad declaration refuses the add-on, so none of its keys
|
|
325
|
+
* merge. A turned-off add-on is not checked: its file is not merged.
|
|
326
|
+
*/
|
|
327
|
+
export function addonResourceTypeProblems(slug, defaults) {
|
|
328
|
+
const types = defaults[RESOURCE_TYPES_KEY];
|
|
329
|
+
if (types === undefined)
|
|
330
|
+
return [];
|
|
331
|
+
if (!isPlainObject(types))
|
|
332
|
+
return [`${RESOURCE_TYPES_KEY} must be an object of resource types`];
|
|
333
|
+
const problems = [];
|
|
334
|
+
const prefix = `${slug}-`;
|
|
335
|
+
for (const [name, entry] of Object.entries(types)) {
|
|
336
|
+
if (!name.startsWith(prefix) || name.length === prefix.length) {
|
|
337
|
+
problems.push(`resource type '${name}' must start with '${prefix}'`);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if (!isPlainObject(entry)) {
|
|
341
|
+
problems.push(`resource type '${name}' must be an object`);
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (entry.attributes === undefined)
|
|
345
|
+
continue;
|
|
346
|
+
if (!isPlainObject(entry.attributes)) {
|
|
347
|
+
problems.push(`resource type '${name}' attributes must be an object`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
for (const [attr, decl] of Object.entries(entry.attributes)) {
|
|
351
|
+
if (!isPlainObject(decl) || typeof decl.type !== 'string' || !ATTRIBUTE_TYPES.has(decl.type)) {
|
|
352
|
+
problems.push(`resource type '${name}' attribute '${attr}' needs a type of string, integer, boolean, identity or list`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (attr === 'owner' && decl.type !== 'identity') {
|
|
356
|
+
problems.push(`resource type '${name}' attribute owner must be { "type": "identity" }, the owner's identity @id`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return problems;
|
|
361
|
+
}
|
|
362
|
+
export function addonResourceTypeLayer(addons) {
|
|
363
|
+
const layer = { refused: new Map(), problems: [] };
|
|
364
|
+
for (const addon of addons) {
|
|
365
|
+
if (!addon.enabled)
|
|
366
|
+
continue;
|
|
367
|
+
const found = addonResourceTypeProblems(addon.slug, addon.defaults);
|
|
368
|
+
if (found.length === 0)
|
|
369
|
+
continue;
|
|
370
|
+
const reason = `its resource types were refused: ${found.join('; ')}`;
|
|
371
|
+
layer.refused.set(addon.slug, reason);
|
|
372
|
+
layer.problems.push(`${addonProblemSubject(addon.slug, addon.enabled)}: ${reason}; the add-on is not loaded`);
|
|
373
|
+
}
|
|
374
|
+
return layer;
|
|
375
|
+
}
|
|
319
376
|
export function addonJobPolicyLayer(addons) {
|
|
320
377
|
const layer = { refused: new Map(), problems: [] };
|
|
321
378
|
for (const addon of addons) {
|
|
@@ -432,9 +489,10 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
|
|
|
432
489
|
const criticalLayer = addonSiteCriticalLayer(addons, custom);
|
|
433
490
|
const sensitiveLayer = addonSensitiveValuesLayer(addons);
|
|
434
491
|
const jobPolicyLayer = addonJobPolicyLayer(addons);
|
|
492
|
+
const resourceTypeLayer = addonResourceTypeLayer(addons);
|
|
435
493
|
const siteCritical = criticalLayer.siteCritical;
|
|
436
494
|
const refused = new Map(layer.refused);
|
|
437
|
-
for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused, ...sensitiveLayer.refused, ...jobPolicyLayer.refused])
|
|
495
|
+
for (const [slug, reason] of [...databaseLayer.refused, ...criticalLayer.refused, ...sensitiveLayer.refused, ...jobPolicyLayer.refused, ...resourceTypeLayer.refused])
|
|
438
496
|
refused.set(slug, refused.has(slug) ? `${refused.get(slug)}; ${reason}` : reason);
|
|
439
497
|
const databases = databaseLayer.declarations.map((d) => ({ ...d, enabled: d.enabled && !refused.has(d.declaredBy) }));
|
|
440
498
|
const withAddons = addons
|
|
@@ -448,14 +506,14 @@ export function mergeWithAddonLayer(shipped, addons, custom) {
|
|
|
448
506
|
return deepMergeObjects(acc, rest);
|
|
449
507
|
}, shipped);
|
|
450
508
|
if (Object.keys(layer.entries).length === 0) {
|
|
451
|
-
return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems, ...jobPolicyLayer.problems], refused, databases, siteCritical };
|
|
509
|
+
return { merged: deepMergeConfigs(withAddons, custom), problems: [...layer.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems, ...jobPolicyLayer.problems, ...resourceTypeLayer.problems], refused, databases, siteCritical };
|
|
452
510
|
}
|
|
453
511
|
const shippedCategories = isPlainObject(withAddons[SYSTEM_CATEGORY_KEY]) ? withAddons[SYSTEM_CATEGORY_KEY] : {};
|
|
454
512
|
const layered = { ...withAddons, [SYSTEM_CATEGORY_KEY]: deepMergeObjects(shippedCategories, layer.entries) };
|
|
455
513
|
const merged = deepMergeObjects(layered, custom);
|
|
456
514
|
const stood = ownerDeclarationsStand(merged[SYSTEM_CATEGORY_KEY], layer, customCategories);
|
|
457
515
|
merged[SYSTEM_CATEGORY_KEY] = stood.categories;
|
|
458
|
-
return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems, ...jobPolicyLayer.problems], refused, databases, siteCritical };
|
|
516
|
+
return { merged: merged, problems: [...layer.problems, ...stood.problems, ...databaseLayer.problems, ...criticalLayer.problems, ...sensitiveLayer.problems, ...jobPolicyLayer.problems, ...resourceTypeLayer.problems], refused, databases, siteCritical };
|
|
459
517
|
}
|
|
460
518
|
/**
|
|
461
519
|
* The full merge for a caller with the two files already read (the manager,
|
|
@@ -48,7 +48,10 @@ import { recordAuditEvent } from './auditEvents.js';
|
|
|
48
48
|
import { addonAuditEventName } from './auditEventNames.js';
|
|
49
49
|
import type { IceReceiver, IceShim } from '../ice/types.js';
|
|
50
50
|
import type { AddonReviews, ReviewHandler } from '../types/Review.js';
|
|
51
|
+
import { type SignedOutWriteRoute } from '../security/signedOutWrites.js';
|
|
51
52
|
import type { AddonManifest } from '../managers/AddonsManager.js';
|
|
53
|
+
import type { Decision } from '../types/Policy.js';
|
|
54
|
+
import type { OwnedListAsk, OwnedListDecision, OwnedRecordAsk } from '../security/ownedRecords.js';
|
|
52
55
|
/** The values core offers an add-on, under the names they were imported as. */
|
|
53
56
|
export interface AddonHost {
|
|
54
57
|
readonly ApiContext: typeof ApiContext;
|
|
@@ -117,6 +120,28 @@ export interface AddonHost {
|
|
|
117
120
|
* context; throw to leave the request open with your message as the reason.
|
|
118
121
|
*/
|
|
119
122
|
readonly registerReviewHandler: (handlerId: string, handler: ReviewHandler) => void;
|
|
123
|
+
/**
|
|
124
|
+
* #2298: every request but GET, HEAD and OPTIONS from someone who is not
|
|
125
|
+
* signed in is refused before your route runs. A route that signed-out people must
|
|
126
|
+
* reach (a public form) is declared here, in `register()`, with the reason:
|
|
127
|
+
* `host.allowSignedOutWrite({ method: 'POST', path: '/api/<slug>/submit/:id', reason })`.
|
|
128
|
+
* The path must be under `/api/<slug>/` or `/addons/<slug>/` (literal
|
|
129
|
+
* segments and `:name` only), and the slug must not be one of core's own
|
|
130
|
+
* prefixes (`RESERVED_ADDON_SLUGS`), or this throws. It only lets the request reach your route: the route still asks its
|
|
131
|
+
* own permission through `ApiContext.requirePermission`.
|
|
132
|
+
*/
|
|
133
|
+
readonly allowSignedOutWrite: (route: SignedOutWriteRoute) => void;
|
|
134
|
+
/**
|
|
135
|
+
* #1823: decisions for this add-on's own records. `decide` is one stored
|
|
136
|
+
* row; `list` is the filter for a list. The resource type must start with
|
|
137
|
+
* this add-on's slug. The manager reads `owner` from the row it stored and
|
|
138
|
+
* passes that, then hands a deny to `ApiContext.enforceDeny` with the
|
|
139
|
+
* resource so the refusal is audited. This records nothing itself.
|
|
140
|
+
*/
|
|
141
|
+
readonly access: {
|
|
142
|
+
decide(subject: ActorContext, request: OwnedRecordAsk): Promise<Decision>;
|
|
143
|
+
list(subject: ActorContext, request: OwnedListAsk): Promise<OwnedListDecision>;
|
|
144
|
+
};
|
|
120
145
|
/**
|
|
121
146
|
* #1979 (ICE, D6): register an import shim, as `<slug>.<shim.name>`. Call it
|
|
122
147
|
* in `register`. Refused when the manifest's `shims` does not name it.
|
|
@@ -47,6 +47,7 @@ import { recordAuditEvent } from './auditEvents.js';
|
|
|
47
47
|
import { addonAuditEventName } from './auditEventNames.js';
|
|
48
48
|
import { auditDeclarationsFrom } from './auditRegistry.js';
|
|
49
49
|
import logger from './logger.js';
|
|
50
|
+
import { declareAddonSignedOutWrite } from '../security/signedOutWrites.js';
|
|
50
51
|
/** A host built outside a site (the test kit, with no setter given) has no account store. */
|
|
51
52
|
const noPersonSwitch = () => Promise.reject(new Error('setEnabledForPerson is not available: this host was built without a site'));
|
|
52
53
|
/**
|
|
@@ -121,6 +122,31 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
|
|
|
121
122
|
decide: async (ctx, id, decision, note) => reviewDoor().decide(ctx, id, decision, note)
|
|
122
123
|
};
|
|
123
124
|
Object.freeze(reviews);
|
|
125
|
+
const recordDoor = () => {
|
|
126
|
+
const door = engine?.getManager('PolicyDecisionPoint');
|
|
127
|
+
if (typeof door?.decideOwnedRecord !== 'function' || typeof door.decideOwnedList !== 'function') {
|
|
128
|
+
throw new Error('record access is not available: this host was built without a site\'s PolicyDecisionPoint');
|
|
129
|
+
}
|
|
130
|
+
return door;
|
|
131
|
+
};
|
|
132
|
+
const ownType = (type) => {
|
|
133
|
+
if (typeof type !== 'string' || !type.startsWith(`${slug}-`)) {
|
|
134
|
+
const message = `Add-on '${slug}' may decide only its own resource types: a type starts '${slug}-'; '${String(type)}' does not`;
|
|
135
|
+
logger.error(`[addons] ${message}`);
|
|
136
|
+
throw new Error(message);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const access = {
|
|
140
|
+
decide: async (subject, request) => {
|
|
141
|
+
ownType(request?.resource?.type);
|
|
142
|
+
return recordDoor().decideOwnedRecord(subject, request);
|
|
143
|
+
},
|
|
144
|
+
list: async (subject, request) => {
|
|
145
|
+
ownType(request?.resource?.type);
|
|
146
|
+
return recordDoor().decideOwnedList(subject, request);
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
Object.freeze(access);
|
|
124
150
|
const hostAddonAuditEventName = (name) => {
|
|
125
151
|
const checked = addonAuditEventName(name);
|
|
126
152
|
refuseUndeclared(slug, name, declared);
|
|
@@ -153,6 +179,8 @@ export function buildAddonHost(slug, addonDefaults, setEnabledForPerson = noPers
|
|
|
153
179
|
zonedParts,
|
|
154
180
|
keyedSerial: () => new KeyedSerial(),
|
|
155
181
|
reviews,
|
|
156
|
-
registerReviewHandler: (handlerId, handler) => reviewDoor().registerHandler(slug, handlerId, handler)
|
|
182
|
+
registerReviewHandler: (handlerId, handler) => reviewDoor().registerHandler(slug, handlerId, handler),
|
|
183
|
+
allowSignedOutWrite: (route) => declareAddonSignedOutWrite(slug, route),
|
|
184
|
+
access
|
|
157
185
|
});
|
|
158
186
|
}
|
|
@@ -100,6 +100,21 @@ export declare function zonedParts(now: Date, timeZone: string): {
|
|
|
100
100
|
year: number;
|
|
101
101
|
month: number;
|
|
102
102
|
};
|
|
103
|
+
/** Whether a schedule is switched on: `enabled: false` turns it off, as the site gate reads it. */
|
|
104
|
+
export declare function scheduleIsEnabled(schedule: Schedule): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* The first thing in a schedule that is not in its exact allowed shape, or
|
|
107
|
+
* `null`. The shape is an allowlist, so a misspelled key reads as a problem,
|
|
108
|
+
* never as an open window: top-level keys `name`, `description`, `message`
|
|
109
|
+
* (text), `timeZone` (a known IANA zone), `enabled` (true or false), `rules`
|
|
110
|
+
* (a list) and `exceptions` (only empty); each rule an object with `type`
|
|
111
|
+
* (`allow`, `deny` or absent), `days` (a non-empty list of weekday names) and
|
|
112
|
+
* `startTime` and `endTime` (HH:MM, both or neither). A `deny` rule closes its
|
|
113
|
+
* whole day, so times on it are a problem rather than silently ignored.
|
|
114
|
+
* Names the path (`rules[1].dayz`), never a value. The site gate lets a
|
|
115
|
+
* malformed schedule stay open; a policy must not (#2261).
|
|
116
|
+
*/
|
|
117
|
+
export declare function scheduleProblem(schedule: Schedule, fallbackZone: string): string | null;
|
|
103
118
|
/**
|
|
104
119
|
* Whether a schedule is open at `now`.
|
|
105
120
|
*
|
|
@@ -78,6 +78,94 @@ function minutesOf(hhmm) {
|
|
|
78
78
|
return null;
|
|
79
79
|
return hours * 60 + mins;
|
|
80
80
|
}
|
|
81
|
+
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
82
|
+
function knownZone(timeZone) {
|
|
83
|
+
try {
|
|
84
|
+
new Intl.DateTimeFormat('en-US', { timeZone });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Whether a schedule is switched on: `enabled: false` turns it off, as the site gate reads it. */
|
|
92
|
+
export function scheduleIsEnabled(schedule) {
|
|
93
|
+
return toBoolean(schedule.enabled, true);
|
|
94
|
+
}
|
|
95
|
+
// Exactly the keys the site gate reads (resolveAvailability, scheduleIsOpen), plus `name`/`description`, which only label.
|
|
96
|
+
// `exceptions` is declared on Schedule but nothing reads it, so only the empty list the shipped schedules carry is accepted.
|
|
97
|
+
const SCHEDULE_KEYS = ['name', 'description', 'timeZone', 'rules', 'exceptions', 'enabled', 'message'];
|
|
98
|
+
const RULE_KEYS = ['type', 'days', 'startTime', 'endTime'];
|
|
99
|
+
function isPlainObject(value) {
|
|
100
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The first thing in a schedule that is not in its exact allowed shape, or
|
|
104
|
+
* `null`. The shape is an allowlist, so a misspelled key reads as a problem,
|
|
105
|
+
* never as an open window: top-level keys `name`, `description`, `message`
|
|
106
|
+
* (text), `timeZone` (a known IANA zone), `enabled` (true or false), `rules`
|
|
107
|
+
* (a list) and `exceptions` (only empty); each rule an object with `type`
|
|
108
|
+
* (`allow`, `deny` or absent), `days` (a non-empty list of weekday names) and
|
|
109
|
+
* `startTime` and `endTime` (HH:MM, both or neither). A `deny` rule closes its
|
|
110
|
+
* whole day, so times on it are a problem rather than silently ignored.
|
|
111
|
+
* Names the path (`rules[1].dayz`), never a value. The site gate lets a
|
|
112
|
+
* malformed schedule stay open; a policy must not (#2261).
|
|
113
|
+
*/
|
|
114
|
+
export function scheduleProblem(schedule, fallbackZone) {
|
|
115
|
+
const top = schedule;
|
|
116
|
+
if (!isPlainObject(top))
|
|
117
|
+
return 'the schedule is not an object';
|
|
118
|
+
for (const key of Object.keys(top)) {
|
|
119
|
+
if (!SCHEDULE_KEYS.includes(key))
|
|
120
|
+
return `${key} is not a schedule setting`;
|
|
121
|
+
}
|
|
122
|
+
for (const key of ['name', 'description', 'message']) {
|
|
123
|
+
if (top[key] !== undefined && typeof top[key] !== 'string')
|
|
124
|
+
return `${key} is not text`;
|
|
125
|
+
}
|
|
126
|
+
if (top.exceptions !== undefined && !(Array.isArray(top.exceptions) && top.exceptions.length === 0)) {
|
|
127
|
+
return 'exceptions is not read by anything and must be empty';
|
|
128
|
+
}
|
|
129
|
+
const zone = schedule.timeZone === undefined ? fallbackZone : schedule.timeZone;
|
|
130
|
+
if (typeof zone !== 'string' || !zone.trim() || !knownZone(zone)) {
|
|
131
|
+
return `${schedule.timeZone === undefined ? 'the site time zone (ngdpbase.time-zone)' : 'timeZone'} is not a known time zone`;
|
|
132
|
+
}
|
|
133
|
+
if (schedule.enabled !== undefined && typeof schedule.enabled !== 'boolean')
|
|
134
|
+
return 'enabled is not true or false';
|
|
135
|
+
if (schedule.rules === undefined)
|
|
136
|
+
return null;
|
|
137
|
+
if (!Array.isArray(schedule.rules))
|
|
138
|
+
return 'rules is not a list';
|
|
139
|
+
for (const [i, rule] of schedule.rules.entries()) {
|
|
140
|
+
const at = `rules[${i}]`;
|
|
141
|
+
if (!isPlainObject(rule))
|
|
142
|
+
return `${at} is not an object`;
|
|
143
|
+
for (const key of Object.keys(rule)) {
|
|
144
|
+
if (!RULE_KEYS.includes(key))
|
|
145
|
+
return `${at}.${key} is not a rule setting`;
|
|
146
|
+
}
|
|
147
|
+
// Only `allow`, `deny` or no type: scheduleIsOpen reads anything but 'deny' as allow, so a typo like "Deny" would fail open.
|
|
148
|
+
if (rule.type !== undefined && rule.type !== 'allow' && rule.type !== 'deny')
|
|
149
|
+
return `${at}.type is not allow or deny`;
|
|
150
|
+
if (rule.days !== undefined) {
|
|
151
|
+
if (!Array.isArray(rule.days) || rule.days.length === 0)
|
|
152
|
+
return `${at}.days is not a non-empty list`;
|
|
153
|
+
if (rule.days.some((d) => typeof d !== 'string' || !WEEKDAYS.includes(d.trim().toLowerCase())))
|
|
154
|
+
return `${at}.days is not a list of weekday names`;
|
|
155
|
+
}
|
|
156
|
+
const given = ['startTime', 'endTime'].filter((f) => rule[f] !== undefined);
|
|
157
|
+
if (rule.type === 'deny' && given.length > 0)
|
|
158
|
+
return `${at}.${given[0]} is ignored on a deny rule, which closes its whole day`;
|
|
159
|
+
// With neither time a rule covers its whole day; with one, or a bad one, it cannot be read.
|
|
160
|
+
if (given.length === 0)
|
|
161
|
+
continue;
|
|
162
|
+
for (const f of ['startTime', 'endTime']) {
|
|
163
|
+
if (minutesOf(rule[f]) === null)
|
|
164
|
+
return `${at}.${f} is not a time of day (HH:MM), or only one of startTime and endTime is given`;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
81
169
|
/**
|
|
82
170
|
* Whether a schedule is open at `now`.
|
|
83
171
|
*
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A save refused because another public page already has the title (#2371),
|
|
3
|
+
* or a name whose slug is the same as that page's (#2376).
|
|
4
|
+
*
|
|
5
|
+
* The message keeps the other page's id, for the log. What the reader sees is
|
|
6
|
+
* {@link pageTitleConflictText} and the link in {@link pageTitleConflictLink}:
|
|
7
|
+
* the title, never the id. A private-store clash is a plain Error that names
|
|
8
|
+
* nothing (#1461), so it is not this class.
|
|
9
|
+
*/
|
|
10
|
+
/** The page that has the name, as the error page and the editor's JSON carry it. */
|
|
11
|
+
export interface ConflictingPage {
|
|
12
|
+
title: string;
|
|
13
|
+
url: string;
|
|
14
|
+
/** Only the slugs match: the view says the name is too close, not taken. */
|
|
15
|
+
tooClose?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare class PageTitleConflictError extends Error {
|
|
18
|
+
readonly title: string;
|
|
19
|
+
readonly tooClose: boolean;
|
|
20
|
+
readonly code = "page-title-conflict";
|
|
21
|
+
/**
|
|
22
|
+
* @param title the title of the page that already has it
|
|
23
|
+
* @param pageId that page's id, for the message only
|
|
24
|
+
* @param requested the title the save asked for, when it differs only by case
|
|
25
|
+
* @param tooClose the titles differ and only their slugs match
|
|
26
|
+
*/
|
|
27
|
+
constructor(title: string, pageId: string, requested?: string, tooClose?: boolean);
|
|
28
|
+
}
|
|
29
|
+
/** What the reader is told. */
|
|
30
|
+
export declare function pageTitleConflictText(err: PageTitleConflictError): string;
|
|
31
|
+
/** Where the title links to: the other page's title and its `/view/…` URL. */
|
|
32
|
+
export declare function pageTitleConflictLink(err: PageTitleConflictError): ConflictingPage;
|