@jwilleke/ngdpbase-types 5.42.0 → 5.43.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 (40) hide show
  1. package/lib/src/context/ApiContext.js +1 -1
  2. package/lib/src/context/BaseContext.d.ts +20 -20
  3. package/lib/src/context/BaseContext.js +41 -24
  4. package/lib/src/context/JobContext.d.ts +31 -0
  5. package/lib/src/context/JobContext.js +34 -1
  6. package/lib/src/managers/AddonsManager.d.ts +0 -28
  7. package/lib/src/managers/AgentTokenManager.d.ts +8 -0
  8. package/lib/src/managers/ConfigurationManager.d.ts +15 -0
  9. package/lib/src/managers/PageManager.d.ts +185 -23
  10. package/lib/src/managers/PolicyEvaluator.d.ts +99 -24
  11. package/lib/src/managers/ShareManager.d.ts +3 -2
  12. package/lib/src/managers/UserManager.d.ts +19 -5
  13. package/lib/src/managers/ValidationManager.d.ts +3 -1
  14. package/lib/src/providers/AgentTokenAuthProvider.js +1 -1
  15. package/lib/src/providers/BaseAuthProvider.d.ts +2 -0
  16. package/lib/src/security/PolicyDecisionPoint.d.ts +57 -1
  17. package/lib/src/security/PolicyInformationPoint.d.ts +86 -6
  18. package/lib/src/security/permissions.generated.d.ts +8 -0
  19. package/lib/src/types/Page.d.ts +0 -7
  20. package/lib/src/types/Policy.d.ts +67 -6
  21. package/lib/src/types/Policy.js +6 -0
  22. package/lib/src/types/Provider.d.ts +12 -0
  23. package/lib/src/types/User.d.ts +6 -0
  24. package/lib/src/utils/addonPageSync.d.ts +0 -12
  25. package/lib/src/utils/auditEventNames.generated.d.ts +2 -0
  26. package/lib/src/utils/auditEventNames.generated.js +2 -0
  27. package/lib/src/utils/auditEvents.d.ts +2 -0
  28. package/lib/src/utils/auditEvents.js +3 -1
  29. package/lib/src/utils/frontmatterAccess.d.ts +15 -24
  30. package/lib/src/utils/legacyStoreKeys.d.ts +16 -0
  31. package/lib/src/utils/pageWritePolicies.d.ts +55 -0
  32. package/lib/src/utils/permissionCatalog.d.ts +2 -0
  33. package/lib/src/utils/permissionCatalog.js +19 -0
  34. package/lib/src/utils/policyAttributes.d.ts +141 -0
  35. package/lib/src/utils/policyAttributes.js +219 -0
  36. package/lib/src/utils/privateStoreAccess.d.ts +24 -4
  37. package/lib/src/utils/privateStoreAccess.js +19 -4
  38. package/lib/src/utils/username.d.ts +13 -2
  39. package/lib/src/utils/username.js +18 -3
  40. package/package.json +1 -1
@@ -253,7 +253,7 @@ export class ApiContext extends BaseContext {
253
253
  // one is asked as #1745 always has: holds the permission, may not act now —
254
254
  // exactly the condition under which the PDP writes STEP_UP_REASON.
255
255
  const stepUp = Boolean(this.request) && await this.holdsPermission(permission);
256
- await this.enforceDeny(permission, { permit: false, applicable: true, reason: stepUp ? STEP_UP_REASON : 'policy_deny' });
256
+ await this.enforceDeny(permission, { permit: false, applicable: true, reason: stepUp ? STEP_UP_REASON : 'policy_deny', policyIds: [] });
257
257
  }
258
258
  /**
259
259
  * Turn a deny into the refusal (#1431) — the one implementation of refusal
@@ -67,19 +67,13 @@ export declare abstract class BaseContext {
67
67
  * asked of the PDP (#1431 step 14): the token and share ceilings, then the
68
68
  * policies, with deny policies and role expansion.
69
69
  *
70
- * For a check against a PAGE, ask `canAccess` on a context that has one —
71
- * resource attributes beat global policy.
72
- *
73
- * #1504: with `attributes` — today `{ 'system-category': … }` — the same
74
- * door asks about pages OF that kind, so a policy on the `system-category`
75
- * resource type can decide (e.g. `page-public`: may this person make pages
76
- * of this system-category public?). It is the policy question alone: unlike
77
- * `canAccess` it walks none of one page's own tiers (private owner,
78
- * frontmatter), so a page's owner gets no pass on it.
70
+ * For a check against a PAGE, ask {@link hasPermissionOn}: the PDP then
71
+ * reads the page's own attributes (its system-category, its owner) from the
72
+ * PIP (#2255). Asked here, a page write (`page-edit`, `-delete`, `-rename`)
73
+ * means "on some category", which is right for an affordance or a token's
74
+ * scope and wrong for a door.
79
75
  */
80
- hasPermission(action: string, attributes?: {
81
- 'system-category'?: unknown;
82
- }): Promise<boolean>;
76
+ hasPermission(action: string): Promise<boolean>;
83
77
  /**
84
78
  * Does this request's subject HOLD the permission (#1635)? Policy alone,
85
79
  * without the step-up freshness rule — for affordances only (show this
@@ -90,16 +84,22 @@ export declare abstract class BaseContext {
90
84
  /** One answer per question for this request — `hasPermission` and `hasPermissionOn` share it. */
91
85
  private cached;
92
86
  /**
93
- * A capability asked about the page it is for (#1539). For a page in a
94
- * vault: the container rule first — the owner, never a role, the same rule
95
- * as Tier 0 of the page tiers — then the capability with the page's
96
- * `vault`, asked about the page itself as the page tiers do — a `page: *`
97
- * grant does not match a vault page's name, so only a policy on the `vault`
98
- * resource type (`vault-owner`) decides there. For any other page, `hasPermission(action)`. A route that acts on
99
- * a named page asks this instead of the bare capability, so a vault page is
100
- * not judged by the caller's site-wide role.
87
+ * A capability asked about the page it is for (#1539, #2255). For a page in
88
+ * a vault: the container rule first — the owner, never a role, the same
89
+ * rule as Tier 0 of the page tiers — then the capability with the page's
90
+ * `vault`, so a policy on the `vault` resource type (`vault-owner`)
91
+ * decides. For any other page, the PDP is asked about the page by name and
92
+ * reads its system-category and owner from the PIP, so the policies on its
93
+ * category decide. A route that acts on a named page asks this instead of
94
+ * the bare capability.
101
95
  */
102
96
  hasPermissionOn(action: string, pageName: string): Promise<boolean>;
97
+ /**
98
+ * {@link hasPermissionOn} as an affordance (#2255): does the subject HOLD
99
+ * `action` on this page, policy alone, without the step-up rule? So the
100
+ * step-up fallback of a page-scoped check asks about the same page.
101
+ */
102
+ holdsPermissionOn(action: string, pageName: string): Promise<boolean>;
103
103
  /**
104
104
  * The identifiers that match audience-style filters — every role, plus the
105
105
  * username when there is one. Used where running the full evaluator per
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
  import { parsePrivatePageName } from '../utils/privateStorePath.js';
3
- import { permitsInVault } from '../utils/privateStoreAccess.js';
3
+ import { holdsInVault, permitsInVault } from '../utils/privateStoreAccess.js';
4
4
  export class BaseContext {
5
5
  /**
6
6
  * The caller's subject as the middleware wrote it, or `null` for a context
@@ -57,25 +57,19 @@ export class BaseContext {
57
57
  * asked of the PDP (#1431 step 14): the token and share ceilings, then the
58
58
  * policies, with deny policies and role expansion.
59
59
  *
60
- * For a check against a PAGE, ask `canAccess` on a context that has one —
61
- * resource attributes beat global policy.
62
- *
63
- * #1504: with `attributes` — today `{ 'system-category': … }` — the same
64
- * door asks about pages OF that kind, so a policy on the `system-category`
65
- * resource type can decide (e.g. `page-public`: may this person make pages
66
- * of this system-category public?). It is the policy question alone: unlike
67
- * `canAccess` it walks none of one page's own tiers (private owner,
68
- * frontmatter), so a page's owner gets no pass on it.
60
+ * For a check against a PAGE, ask {@link hasPermissionOn}: the PDP then
61
+ * reads the page's own attributes (its system-category, its owner) from the
62
+ * PIP (#2255). Asked here, a page write (`page-edit`, `-delete`, `-rename`)
63
+ * means "on some category", which is right for an affordance or a token's
64
+ * scope and wrong for a door.
69
65
  */
70
- async hasPermission(action, attributes) {
66
+ async hasPermission(action) {
71
67
  const pdp = this.engineRef.getManager('PolicyDecisionPoint');
72
68
  if (!pdp)
73
69
  return false;
74
70
  // #637: hand over the already-resolved subject so the PDP need not
75
71
  // resolve the account and its roles again.
76
- return this.cached(attributes ? `${action}\u0000${JSON.stringify(attributes)}` : action, () => (attributes
77
- ? pdp.decide(this.getActor(), { action, resource: { type: 'page', id: '*' }, attributes }).then((d) => d.permit)
78
- : pdp.permits(this.getActor(), action)));
72
+ return this.cached(action, () => pdp.permits(this.getActor(), action));
79
73
  }
80
74
  /**
81
75
  * Does this request's subject HOLD the permission (#1635)? Policy alone,
@@ -101,24 +95,47 @@ export class BaseContext {
101
95
  return promise;
102
96
  }
103
97
  /**
104
- * A capability asked about the page it is for (#1539). For a page in a
105
- * vault: the container rule first — the owner, never a role, the same rule
106
- * as Tier 0 of the page tiers — then the capability with the page's
107
- * `vault`, asked about the page itself as the page tiers do — a `page: *`
108
- * grant does not match a vault page's name, so only a policy on the `vault`
109
- * resource type (`vault-owner`) decides there. For any other page, `hasPermission(action)`. A route that acts on
110
- * a named page asks this instead of the bare capability, so a vault page is
111
- * not judged by the caller's site-wide role.
98
+ * A capability asked about the page it is for (#1539, #2255). For a page in
99
+ * a vault: the container rule first — the owner, never a role, the same
100
+ * rule as Tier 0 of the page tiers — then the capability with the page's
101
+ * `vault`, so a policy on the `vault` resource type (`vault-owner`)
102
+ * decides. For any other page, the PDP is asked about the page by name and
103
+ * reads its system-category and owner from the PIP, so the policies on its
104
+ * category decide. A route that acts on a named page asks this instead of
105
+ * the bare capability.
112
106
  */
113
107
  async hasPermissionOn(action, pageName) {
114
108
  const name = parsePrivatePageName(pageName);
115
- if (!name)
116
- return this.hasPermission(action);
109
+ if (!name) {
110
+ const pdp = this.engineRef.getManager('PolicyDecisionPoint');
111
+ if (!pdp)
112
+ return false;
113
+ // A stand-in decider with no `decide` (test doubles) answers the capability, as `holdsPermission` does.
114
+ if (typeof pdp.decide !== 'function')
115
+ return this.hasPermission(action);
116
+ return this.cached(`${action}\u0000${pageName}`, () => pdp.decide(this.getActor(), { action, resource: { type: 'page', id: pageName } }).then((d) => d.permit));
117
+ }
117
118
  if (!this.hasSubject())
118
119
  return false;
119
120
  const pdp = this.engineRef.getManager('PolicyDecisionPoint');
120
121
  return this.cached(`${action}\u0000${pageName}`, () => permitsInVault(pdp, this.getActor(), action, { owner: name.owner, vault: name.store, resource: pageName }));
121
122
  }
123
+ /**
124
+ * {@link hasPermissionOn} as an affordance (#2255): does the subject HOLD
125
+ * `action` on this page, policy alone, without the step-up rule? So the
126
+ * step-up fallback of a page-scoped check asks about the same page.
127
+ */
128
+ async holdsPermissionOn(action, pageName) {
129
+ const pdp = this.engineRef.getManager('PolicyDecisionPoint');
130
+ if (!pdp || typeof pdp.holds !== 'function')
131
+ return this.hasPermissionOn(action, pageName);
132
+ const name = parsePrivatePageName(pageName);
133
+ if (!name)
134
+ return this.cached(`holds\u0000${action}\u0000${pageName}`, () => pdp.holds(this.getActor(), action, { resource: { type: 'page', id: pageName } }));
135
+ if (!this.hasSubject())
136
+ return false;
137
+ return this.cached(`holds\u0000${action}\u0000${pageName}`, () => holdsInVault(pdp, this.getActor(), action, { owner: name.owner, vault: name.store, resource: pageName }));
138
+ }
122
139
  /**
123
140
  * The identifiers that match audience-style filters — every role, plus the
124
141
  * username when there is one. Used where running the full evaluator per
@@ -67,6 +67,15 @@ export interface JobContext {
67
67
  viaProxy?: ProxyClaim;
68
68
  /** The address the requesting call came from (#1179), when there was one. Provenance for the record. */
69
69
  ipAddress?: string;
70
+ /**
71
+ * The assurance level (1 to 3) of the requester's session when the work was
72
+ * requested (#2261), which is the job's `subject:aal` whenever it runs. Taken
73
+ * from the requester's own session or token, never from an argument, so it
74
+ * cannot exceed it. Absent for work no session asked for (boot, a schedule,
75
+ * the command line) and for a job requested before it was stored: its
76
+ * `subject:aal` is missing and a condition on it fails closed.
77
+ */
78
+ aal?: 1 | 2 | 3;
70
79
  /** ISO 8601. When the work was requested, not when it ran. */
71
80
  requestedAt: string;
72
81
  /** Why, for origins with no person to ask. Free text, for the audit record. */
@@ -84,10 +93,23 @@ export interface JobContext {
84
93
  /** The shape a request-bound context exposes; both WikiContext and ApiContext satisfy it. */
85
94
  export interface RequestIdentity {
86
95
  username?: string | null;
96
+ /** With `signIn`, `aalCap` and `viaToken`, what the level the job stores is read from (#2261). */
97
+ isAuthenticated?: boolean;
98
+ signIn?: {
99
+ aal?: unknown;
100
+ } | null;
101
+ aalCap?: number;
87
102
  viaToken?: AgentTokenGrant;
88
103
  viaShare?: ShareGrant;
89
104
  viaProxy?: ProxyClaim;
90
105
  ipAddress?: string;
106
+ /** An `ApiContext` holds the request's subject here, a `WikiContext` as `userContext`: the level is read from that, not from the wrapper (#2287). */
107
+ subject?: object | null;
108
+ userContext?: object | null;
109
+ /** A `JobContext` handed back in: it already holds the level its requester's session had. */
110
+ origin?: unknown;
111
+ requestedAt?: unknown;
112
+ aal?: unknown;
91
113
  }
92
114
  /**
93
115
  * The username a request-origin job carries when the request had no identity.
@@ -150,6 +172,15 @@ export declare function jobContextFromOperator(username: string, reason: string,
150
172
  * a context to pass, including code that runs with no request at all.
151
173
  */
152
174
  export declare function jobContextFromSystem(systemPrincipal: string, reason: string, now?: Date): JobContext;
175
+ /**
176
+ * Boot work done in one user's own vaults, on that user's behalf (#1815) —
177
+ * the start-up vault upgrades. The job carries the owner's username, so the
178
+ * vault container rule admits it as it admits any job carrying who asked, and
179
+ * what it may do there is the owner's own rights, resolved by policy when
180
+ * each write is decided. Never the system principal, which no vault admits.
181
+ * Like every job it carries no key, so an encrypted vault stays closed to it.
182
+ */
183
+ export declare function jobContextForOwner(owner: string, reason: string, now?: Date): JobContext;
153
184
  /** As {@link jobContextFromSystem}, for work started by a timer rather than at boot. */
154
185
  export declare function jobContextFromSchedule(systemPrincipal: string, reason: string, now?: Date): JobContext;
155
186
  /**
@@ -30,6 +30,7 @@
30
30
  * enqueued by a token-bearing request escapes the agent-token ceiling simply by
31
31
  * becoming asynchronous.
32
32
  */
33
+ import { effectiveAal, toAal } from '../utils/policyAttributes.js';
33
34
  /**
34
35
  * The username a request-origin job carries when the request had no identity.
35
36
  *
@@ -52,6 +53,7 @@ export function jobContextFromRequest(identity, now = new Date()) {
52
53
  return {
53
54
  username: identity?.username ?? ANONYMOUS_USERNAME,
54
55
  origin: 'request',
56
+ ...aalOf(identity),
55
57
  ...(identity?.viaToken ? { viaToken: identity.viaToken } : {}),
56
58
  ...(identity?.viaShare ? { viaShare: identity.viaShare } : {}),
57
59
  ...(identity?.viaProxy ? { viaProxy: proxyClaimForJob(identity.viaProxy) } : {}),
@@ -59,6 +61,24 @@ export function jobContextFromRequest(identity, now = new Date()) {
59
61
  requestedAt: now.toISOString()
60
62
  };
61
63
  }
64
+ /**
65
+ * The level the requester's session or token has right now (#2261), as a field
66
+ * to store with the job; nothing when it has none. Read from the granting
67
+ * session's own subject whatever the caller passed (#2287): a request's subject
68
+ * as is, an `ApiContext`'s `subject`, a `WikiContext`'s `userContext`, or a
69
+ * `JobContext` handed back in, whose stored level is kept as it is.
70
+ */
71
+ function aalOf(identity) {
72
+ if (!identity)
73
+ return {};
74
+ if (typeof identity.origin === 'string' && typeof identity.requestedAt === 'string') {
75
+ const kept = toAal(identity.aal);
76
+ return kept ? { aal: kept } : {};
77
+ }
78
+ const wrapped = identity.subject ?? identity.userContext;
79
+ const aal = effectiveAal(wrapped && typeof wrapped === 'object' ? (wrapped) : identity);
80
+ return aal ? { aal } : {};
81
+ }
62
82
  /** The proxy claim without its key handle (#2047): the actor, grant and scopes stay for the ceiling and the record. */
63
83
  function proxyClaimForJob(claim) {
64
84
  const { keyHandle: _dropped, ...kept } = claim;
@@ -119,6 +139,17 @@ export function jobContextFromSystem(systemPrincipal, reason, now = new Date())
119
139
  reason
120
140
  };
121
141
  }
142
+ /**
143
+ * Boot work done in one user's own vaults, on that user's behalf (#1815) —
144
+ * the start-up vault upgrades. The job carries the owner's username, so the
145
+ * vault container rule admits it as it admits any job carrying who asked, and
146
+ * what it may do there is the owner's own rights, resolved by policy when
147
+ * each write is decided. Never the system principal, which no vault admits.
148
+ * Like every job it carries no key, so an encrypted vault stays closed to it.
149
+ */
150
+ export function jobContextForOwner(owner, reason, now = new Date()) {
151
+ return { username: owner, origin: 'boot', requestedAt: now.toISOString(), reason };
152
+ }
122
153
  /** As {@link jobContextFromSystem}, for work started by a timer rather than at boot. */
123
154
  export function jobContextFromSchedule(systemPrincipal, reason, now = new Date()) {
124
155
  return { ...jobContextFromSystem(systemPrincipal, reason, now), origin: 'schedule' };
@@ -149,7 +180,9 @@ export function toPermissionSubject(ctx) {
149
180
  ...(ctx.viaShare ? { viaShare: ctx.viaShare } : {}),
150
181
  ...(ctx.viaProxy ? { viaProxy: ctx.viaProxy } : {}),
151
182
  // #1999: the job a scheduled run acts as, for the PIP to resolve.
152
- ...(ctx.job ? { job: ctx.job } : {})
183
+ ...(ctx.job ? { job: ctx.job } : {}),
184
+ // #2261: the level stored when it was requested, for `subject:aal`.
185
+ ...(ctx.aal ? { grantedAal: ctx.aal } : {})
153
186
  };
154
187
  }
155
188
  /** One line for a log or an audit record: who, from where, and how. */
@@ -604,34 +604,6 @@ declare class AddonsManager extends BaseManager {
604
604
  * @param addonName Name of the add-on (for logging)
605
605
  * @param addonPath Filesystem path to the add-on directory
606
606
  */
607
- /**
608
- * Default edit-protection for a seeded addon page (#971, addons.md §3).
609
- *
610
- * §3 settled the mechanism: the seeder stamps frontmatter `access`, which
611
- * Tier 1 of the evaluator honours directly, needs no `PolicyEvaluator`
612
- * change, and is hash-neutral (`pageSourceHash` covers the body only, so
613
- * adding metadata cannot disturb the #920 reseed comparison).
614
- *
615
- * §9 settled *who owns what*, and the two must be read together. A blanket
616
- * admin lock would contradict the operator's decision that domain content
617
- * pages "should be normal pages, purely seeded by the addon" — the addon
618
- * provides a starting corpus and then lets go. So the default follows §9's
619
- * ownership column rather than locking everything:
620
- *
621
- * | Category | Purpose | Default |
622
- * |---|---|---|
623
- * | `system` | Feature UI, site chrome — infrastructure | admin-only edit |
624
- * | `documentation` | Help / docs — addon-owned | admin-only edit |
625
- * | `general` | Domain content, demo — **instance-owned** | no stamp |
626
- * | anything else | unclassified, treated as addon-owned | admin-only edit |
627
- *
628
- * An addon can always override by declaring its own `access`, which is the
629
- * documented escape hatch in §3 and the reason the default is a default.
630
- *
631
- * @param category - The page's resolved `system-category`
632
- * @returns The access object to stamp, or undefined to leave the page unprotected
633
- */
634
- private defaultAddonPageAccess;
635
607
  /**
636
608
  * Surface a page-level seed failure beyond the boot log (#951).
637
609
  *
@@ -82,6 +82,14 @@ export interface AgentTokenRecord {
82
82
  approvedBy?: string;
83
83
  /** The proxy grant it was minted under. Ending that grant ends this token; it works only while the grant is in force. */
84
84
  proxyGrantId?: string;
85
+ /**
86
+ * #2261: the assurance level (1 to 3) of the minting session when the token
87
+ * was made, which is `subject:aal` for every request the token makes. Taken
88
+ * from the session, never from the request, so it cannot exceed it. Absent
89
+ * on a token made before this was stored: its `subject:aal` is missing and a
90
+ * condition on it fails closed.
91
+ */
92
+ aal?: 1 | 2 | 3;
85
93
  }
86
94
  /** A record safe to return over the API — no hash. */
87
95
  export type AgentTokenPublic = Omit<AgentTokenRecord, 'hash'>;
@@ -200,6 +200,21 @@ declare class ConfigurationManager extends BaseManager {
200
200
  * @throws {Error} If default configuration file cannot be loaded
201
201
  */
202
202
  private loadConfigurations;
203
+ /** What the #2255 page-write migration rewrote in memory, one sentence each; PolicyDecisionPoint reports it. */
204
+ pageWritePolicyMigration: string[];
205
+ /**
206
+ * #2255: before the merge, rewrite the custom layer's stored copies of the
207
+ * shipped role policies whose page writes moved to the category policies
208
+ * (`migrateStoredPageWritePolicies`). In memory only, like the other
209
+ * migrations here; one warning per policy changed.
210
+ */
211
+ private migrateStoredPageWritePolicies;
212
+ /**
213
+ * #2246: `ngdpbase.stores.<kind>.owner|encrypt` in the custom config have
214
+ * been ignored since #1505. One warning per key, and one needs-attention
215
+ * entry naming them all. Nothing is changed, carried over or removed.
216
+ */
217
+ private reportLegacyStoreKeys;
203
218
  /**
204
219
  * #642: One-shot migration shim. If the custom config sets either of the
205
220
  * legacy keys `ngdpbase.base-url` or `ngdpbase.baseURL`, copy the value