@homeflare/alchemy 0.5.0 → 0.6.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/README.md +17 -48
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1433 -305
- package/dist/openbao/index.js.map +34 -13
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +87 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +16 -1
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
package/src/openbao/mount.ts
CHANGED
|
@@ -7,21 +7,25 @@
|
|
|
7
7
|
*
|
|
8
8
|
* ★ `defaultRemovalPolicy: 'retain'` — disabling a mount destroys every secret under it.
|
|
9
9
|
* Opt in with `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
|
|
10
|
+
*
|
|
11
|
+
* ★ REPLACE SEMANTICS (audited 2026-09-21, see src/openbao/REPLACE.md):
|
|
12
|
+
* · `type` changed → plans `replace`, and the APPLY FAILS, destroying nothing: the new
|
|
13
|
+
* generation's path is the old one's, still occupied, so its reconcile dies "type is
|
|
14
|
+
* immutable" before any write (mount-reconcile.ts). Change a type by hand. ⛔ Not `deleteFirst`
|
|
15
|
+
* on purpose — under `destroy` that would disable the mount, every secret with it, on a typo.
|
|
16
|
+
* · `path` changed → FAILS the plan unless `remountFrom` names the old path, which is an
|
|
17
|
+
* in-place `update` that moves the mount and its data (mount-move.ts). It used to be an
|
|
18
|
+
* `update` that enabled an EMPTY mount at the new path.
|
|
10
19
|
*/
|
|
11
20
|
import { Resource } from 'alchemy';
|
|
12
21
|
import { isResolved } from 'alchemy/Diff';
|
|
13
22
|
import * as Provider from 'alchemy/Provider';
|
|
14
23
|
import * as Effect from 'effect/Effect';
|
|
15
24
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
matches,
|
|
21
|
-
mountPath,
|
|
22
|
-
wantsTune,
|
|
23
|
-
} from './mount-form.ts';
|
|
24
|
-
import { disableMount, enableMount, readMountData, tuneMount } from './mount-wire.ts';
|
|
25
|
+
import { type BaoMountAttributes, type BaoMountProps, matches } from './mount-form.ts';
|
|
26
|
+
import { planMove } from './mount-move.ts';
|
|
27
|
+
import { readMount, reconcileMount } from './mount-reconcile.ts';
|
|
28
|
+
import { disableMount } from './mount-wire.ts';
|
|
25
29
|
|
|
26
30
|
export type { BaoMountAttributes, BaoMountProps };
|
|
27
31
|
|
|
@@ -37,13 +41,6 @@ export const BaoMount = Resource<BaoMount>('Bao.Mount', {
|
|
|
37
41
|
defaultRemovalPolicy: 'retain',
|
|
38
42
|
});
|
|
39
43
|
|
|
40
|
-
const readMount = (props: BaoMountProps) =>
|
|
41
|
-
Effect.gen(function* () {
|
|
42
|
-
const live = yield* readMountData(props.path);
|
|
43
|
-
if (live === undefined) return undefined;
|
|
44
|
-
return attributesOf(props, live);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
44
|
export const BaoMountProvider = () =>
|
|
48
45
|
Provider.effect(
|
|
49
46
|
BaoMount,
|
|
@@ -64,6 +61,13 @@ export const BaoMountProvider = () =>
|
|
|
64
61
|
|
|
65
62
|
diff: Effect.fn(function* ({ news, output }) {
|
|
66
63
|
if (output === undefined || !isResolved(news)) return undefined;
|
|
64
|
+
/**
|
|
65
|
+
* ⛔ A CHANGED PATH IS DECIDED HERE, BEFORE ANY READ OF THE NEW PATH — reading it would
|
|
66
|
+
* find nothing and plan `update`, which is how an empty mount used to get enabled.
|
|
67
|
+
*/
|
|
68
|
+
const move = planMove('Bao.Mount', output.path, news.path, news.remountFrom);
|
|
69
|
+
if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
|
|
70
|
+
if (move.kind === 'move') return { action: 'update' } as const;
|
|
67
71
|
const live = yield* readMount(news);
|
|
68
72
|
if (live === undefined) return { action: 'update' } as const;
|
|
69
73
|
/**
|
|
@@ -77,8 +81,9 @@ export const BaoMountProvider = () =>
|
|
|
77
81
|
* it is true the engine logs "Retaining replaced resource (removal policy: retain)"
|
|
78
82
|
* and skips the delete entirely. Every mount this package declares carries the retain
|
|
79
83
|
* default (see the ★ on the resource below), so a wrong `type` string cannot disable a
|
|
80
|
-
* live mount. What it CAN do is
|
|
81
|
-
*
|
|
84
|
+
* live mount. What it CAN do is fail: the new generation reads the SAME path, finds the
|
|
85
|
+
* old type there and dies before any write (mount-reconcile.ts) — a failed deploy, not a
|
|
86
|
+
* lost shelf (corrected 2026-09-21; this said OpenBao refused a second enable).
|
|
82
87
|
* ⚠️ THAT SAFETY IS THE DECORATION'S, NOT THIS LINE'S. A caller who opts into
|
|
83
88
|
* `.pipe(RemovalPolicy.destroy())` gets the destructive replace, which is the correct
|
|
84
89
|
* meaning of opting in and is worth knowing before you type it on a mount.
|
|
@@ -91,32 +96,10 @@ export const BaoMountProvider = () =>
|
|
|
91
96
|
/**
|
|
92
97
|
* ★ A REFUSED ENABLE OR TUNE FAILS THE EFFECT WITH OpenBao's OWN `errors`, naming the
|
|
93
98
|
* method and path — the job the per-command exit-code checks used to do by hand.
|
|
99
|
+
* The body, and the move, live in mount-reconcile.ts.
|
|
94
100
|
*/
|
|
95
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
96
|
-
|
|
97
|
-
if (live === undefined) {
|
|
98
|
-
yield* enableMount(news);
|
|
99
|
-
if (wantsTune(news)) yield* tuneMount(news);
|
|
100
|
-
} else if (!matches(live, news)) {
|
|
101
|
-
if (live.type !== news.type) {
|
|
102
|
-
return yield* Effect.die(
|
|
103
|
-
new Error(
|
|
104
|
-
`Bao.Mount ${mountPath(news.path)}: live type ${live.type} != ${news.type}. ` +
|
|
105
|
-
'Secrets engine type is immutable — replace manually.',
|
|
106
|
-
),
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
yield* tuneMount(news);
|
|
110
|
-
}
|
|
111
|
-
live = yield* readMount(news);
|
|
112
|
-
if (live === undefined) {
|
|
113
|
-
return yield* Effect.die(
|
|
114
|
-
new Error(
|
|
115
|
-
`Bao.Mount ${mountPath(news.path)}: write returned no error but the mount is still absent.`,
|
|
116
|
-
),
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
return live;
|
|
101
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
102
|
+
return yield* reconcileMount(news, output?.path);
|
|
120
103
|
}),
|
|
121
104
|
|
|
122
105
|
/**
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.PkiRole's canonical form — the managed field set, normalised so both sides of a diff are
|
|
3
|
+
* byte-comparable. Split from pki-role-form.ts for the 250-line cap when six fields were added
|
|
4
|
+
* (2026-09-21); the reasoning below moved with the code, unchanged.
|
|
5
|
+
*
|
|
6
|
+
* ★ THE SIX FIELDS ADDED 2026-09-21 — require_cn, enforce_hostnames, key_usage,
|
|
7
|
+
* allowed_domains_template, no_store, generate_lease — WERE ALREADY BEING WRITTEN, just never
|
|
8
|
+
* declared. The role write is a full replace (pki-role.ts), and openbao v2.6.2
|
|
9
|
+
* builtin/logical/pki/path_roles.go pathRoleCreate (:1153-1196) builds each of them from
|
|
10
|
+
* `data.Get`, i.e. its schema default when the request omits it. So every write this resource
|
|
11
|
+
* ever made reset them — a hand-set `no_store` or a narrowed `key_usage` vanished behind a
|
|
12
|
+
* green plan. Their defaults below are OpenBao's own (schema :560-575, :690-720, :800-845), so a
|
|
13
|
+
* role this resource wrote reads back unchanged and plans `noop`.
|
|
14
|
+
*/
|
|
15
|
+
import { sha256 } from './digest.ts';
|
|
16
|
+
import { parseDuration, ttlSeconds } from './mount-form.ts';
|
|
17
|
+
import type { BaoPkiRoleProps } from './pki-role-form.ts';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The managed field set, normalised so both sides of a diff are byte-comparable.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ KEY ORDER IS THE DIGEST. The props side and the live side are both built by the single
|
|
23
|
+
* canonical() literal below, so `JSON.stringify` emits the same key order for each. Two
|
|
24
|
+
* hand-written object literals would drift apart and produce a forever-diff nobody could
|
|
25
|
+
* read from the plan output.
|
|
26
|
+
*/
|
|
27
|
+
export interface BaoPkiRoleCanonical {
|
|
28
|
+
allowBareDomains: boolean;
|
|
29
|
+
allowGlobDomains: boolean;
|
|
30
|
+
allowIpSans: boolean;
|
|
31
|
+
allowLocalhost: boolean;
|
|
32
|
+
allowSubdomains: boolean;
|
|
33
|
+
allowWildcardCertificates: boolean;
|
|
34
|
+
allowedDomains: readonly string[];
|
|
35
|
+
allowedDomainsTemplate: boolean;
|
|
36
|
+
clientFlag: boolean;
|
|
37
|
+
cnValidations: readonly string[];
|
|
38
|
+
enforceHostnames: boolean;
|
|
39
|
+
extKeyUsage: readonly string[];
|
|
40
|
+
generateLease: boolean;
|
|
41
|
+
keyBits: number;
|
|
42
|
+
keyType: string;
|
|
43
|
+
keyUsage: readonly string[];
|
|
44
|
+
/** ⚠️ `-1` means the duration prop did not parse — reconcile refuses rather than writing it. */
|
|
45
|
+
maxTtlSeconds: number;
|
|
46
|
+
noStore: boolean;
|
|
47
|
+
requireCn: boolean;
|
|
48
|
+
serverFlag: boolean;
|
|
49
|
+
signatureBits: number;
|
|
50
|
+
ttlSeconds: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** ★ OpenBao's own `key_usage` default (path_roles.go:697-698) — what an omitted field becomes. */
|
|
54
|
+
export const DEFAULT_KEY_USAGE: readonly string[] = [
|
|
55
|
+
'DigitalSignature',
|
|
56
|
+
'KeyAgreement',
|
|
57
|
+
'KeyEncipherment',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const sorted = (values: readonly string[]) => [...values].sort();
|
|
61
|
+
|
|
62
|
+
/** ⚠️ Case-folded for the COMPARE only; the write keeps the author's casing.
|
|
63
|
+
* Why, and what was never measured, is in the pki-role.ts header. `key_usage` gets the same
|
|
64
|
+
* treatment as `ext_key_usage`, for the same reason — REASONED, not measured. */
|
|
65
|
+
const foldedUsages = (values: readonly string[]) => sorted(values.map((v) => v.toLowerCase()));
|
|
66
|
+
|
|
67
|
+
/** The one literal that fixes key order for both sides of the diff. */
|
|
68
|
+
const canonical = (input: BaoPkiRoleCanonical): BaoPkiRoleCanonical => ({
|
|
69
|
+
allowBareDomains: input.allowBareDomains,
|
|
70
|
+
allowGlobDomains: input.allowGlobDomains,
|
|
71
|
+
allowIpSans: input.allowIpSans,
|
|
72
|
+
allowLocalhost: input.allowLocalhost,
|
|
73
|
+
allowSubdomains: input.allowSubdomains,
|
|
74
|
+
allowWildcardCertificates: input.allowWildcardCertificates,
|
|
75
|
+
allowedDomains: sorted(input.allowedDomains),
|
|
76
|
+
allowedDomainsTemplate: input.allowedDomainsTemplate,
|
|
77
|
+
clientFlag: input.clientFlag,
|
|
78
|
+
cnValidations: sorted(input.cnValidations),
|
|
79
|
+
enforceHostnames: input.enforceHostnames,
|
|
80
|
+
extKeyUsage: foldedUsages(input.extKeyUsage),
|
|
81
|
+
generateLease: input.generateLease,
|
|
82
|
+
keyBits: input.keyBits,
|
|
83
|
+
keyType: input.keyType,
|
|
84
|
+
keyUsage: foldedUsages(input.keyUsage),
|
|
85
|
+
maxTtlSeconds: input.maxTtlSeconds,
|
|
86
|
+
noStore: input.noStore,
|
|
87
|
+
requireCn: input.requireCn,
|
|
88
|
+
serverFlag: input.serverFlag,
|
|
89
|
+
signatureBits: input.signatureBits,
|
|
90
|
+
ttlSeconds: input.ttlSeconds,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const seconds = (text: string) => parseDuration(text) ?? -1;
|
|
94
|
+
|
|
95
|
+
export const canonicalFromProps = (props: BaoPkiRoleProps): BaoPkiRoleCanonical =>
|
|
96
|
+
canonical({
|
|
97
|
+
allowBareDomains: props.allowBareDomains ?? false,
|
|
98
|
+
allowGlobDomains: props.allowGlobDomains ?? false,
|
|
99
|
+
allowIpSans: props.allowIpSans ?? false,
|
|
100
|
+
allowLocalhost: props.allowLocalhost ?? false,
|
|
101
|
+
allowSubdomains: props.allowSubdomains ?? false,
|
|
102
|
+
allowWildcardCertificates: props.allowWildcardCertificates ?? false,
|
|
103
|
+
allowedDomains: props.allowedDomains,
|
|
104
|
+
allowedDomainsTemplate: props.allowedDomainsTemplate ?? false,
|
|
105
|
+
clientFlag: props.clientFlag ?? true,
|
|
106
|
+
cnValidations: props.cnValidations ?? ['hostname'],
|
|
107
|
+
enforceHostnames: props.enforceHostnames ?? true,
|
|
108
|
+
extKeyUsage: props.extKeyUsage ?? [],
|
|
109
|
+
generateLease: props.generateLease ?? false,
|
|
110
|
+
keyBits: props.keyBits ?? 0,
|
|
111
|
+
keyType: props.keyType ?? 'rsa',
|
|
112
|
+
keyUsage: props.keyUsage ?? DEFAULT_KEY_USAGE,
|
|
113
|
+
maxTtlSeconds: seconds(props.maxTtl),
|
|
114
|
+
noStore: props.noStore ?? false,
|
|
115
|
+
requireCn: props.requireCn ?? true,
|
|
116
|
+
serverFlag: props.serverFlag ?? true,
|
|
117
|
+
signatureBits: props.signatureBits ?? 0,
|
|
118
|
+
ttlSeconds: seconds(props.ttl),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const bool = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback);
|
|
122
|
+
|
|
123
|
+
const int = (value: unknown, fallback: number) =>
|
|
124
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
125
|
+
|
|
126
|
+
const list = (value: unknown): readonly string[] =>
|
|
127
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
128
|
+
|
|
129
|
+
const str = (value: unknown, fallback: string) => (typeof value === 'string' ? value : fallback);
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* ⚠️ AN ABSENT NEW FIELD READS AS ITS ZERO VALUE, NOT ITS SCHEMA DEFAULT. A role read always carries
|
|
133
|
+
* the six new fields (ToResponseData, path_roles.go:1720-1780; `generate_lease` only when set,
|
|
134
|
+
* and getRole upgrades a missing one to false at :1034), so the fallback only answers a body
|
|
135
|
+
* that is not OpenBao's — and then "false"/"none" reads as drift rather than a false noop.
|
|
136
|
+
*/
|
|
137
|
+
export const canonicalFromLive = (live: Record<string, unknown>): BaoPkiRoleCanonical =>
|
|
138
|
+
canonical({
|
|
139
|
+
allowBareDomains: bool(live['allow_bare_domains'], false),
|
|
140
|
+
allowGlobDomains: bool(live['allow_glob_domains'], false),
|
|
141
|
+
allowIpSans: bool(live['allow_ip_sans'], false),
|
|
142
|
+
allowLocalhost: bool(live['allow_localhost'], false),
|
|
143
|
+
allowSubdomains: bool(live['allow_subdomains'], false),
|
|
144
|
+
allowWildcardCertificates: bool(live['allow_wildcard_certificates'], false),
|
|
145
|
+
allowedDomains: list(live['allowed_domains']),
|
|
146
|
+
allowedDomainsTemplate: bool(live['allowed_domains_template'], false),
|
|
147
|
+
clientFlag: bool(live['client_flag'], true),
|
|
148
|
+
cnValidations: list(live['cn_validations']),
|
|
149
|
+
enforceHostnames: bool(live['enforce_hostnames'], false),
|
|
150
|
+
extKeyUsage: list(live['ext_key_usage']),
|
|
151
|
+
generateLease: bool(live['generate_lease'], false),
|
|
152
|
+
keyBits: int(live['key_bits'], 0),
|
|
153
|
+
keyType: str(live['key_type'], 'rsa'),
|
|
154
|
+
keyUsage: list(live['key_usage']),
|
|
155
|
+
maxTtlSeconds: ttlSeconds(live['max_ttl']) ?? -1,
|
|
156
|
+
noStore: bool(live['no_store'], false),
|
|
157
|
+
requireCn: bool(live['require_cn'], false),
|
|
158
|
+
serverFlag: bool(live['server_flag'], true),
|
|
159
|
+
signatureBits: int(live['signature_bits'], 0),
|
|
160
|
+
ttlSeconds: ttlSeconds(live['ttl']) ?? -1,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
export const digestOf = (form: BaoPkiRoleCanonical): string => sha256(JSON.stringify(form));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.PkiRole's six fields added 2026-09-21. The part worth pinning: a role written BEFORE they
|
|
3
|
+
* were props carries OpenBao's defaults for them, and must still plan `noop`; a role that differs
|
|
4
|
+
* on any of them must plan `update`, because the full-replace write would reset it.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
type BaoPkiRoleProps,
|
|
10
|
+
attributesOf,
|
|
11
|
+
matches,
|
|
12
|
+
problems,
|
|
13
|
+
writeBody,
|
|
14
|
+
} from './pki-role-form.ts';
|
|
15
|
+
|
|
16
|
+
const PROPS: BaoPkiRoleProps = {
|
|
17
|
+
allowedDomains: ['svc.example.com'],
|
|
18
|
+
allowSubdomains: true,
|
|
19
|
+
maxTtl: '8760h',
|
|
20
|
+
name: 'host-cert',
|
|
21
|
+
ttl: '720h',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** What OpenBao 2.6.2 answers for a role this resource wrote before the six were props. */
|
|
25
|
+
const LIVE: Record<string, unknown> = {
|
|
26
|
+
allow_bare_domains: false,
|
|
27
|
+
allow_glob_domains: false,
|
|
28
|
+
allow_ip_sans: false,
|
|
29
|
+
allow_localhost: false,
|
|
30
|
+
allow_subdomains: true,
|
|
31
|
+
allow_wildcard_certificates: false,
|
|
32
|
+
allowed_domains: ['svc.example.com'],
|
|
33
|
+
allowed_domains_template: false,
|
|
34
|
+
client_flag: true,
|
|
35
|
+
cn_validations: ['hostname'],
|
|
36
|
+
enforce_hostnames: true,
|
|
37
|
+
ext_key_usage: [],
|
|
38
|
+
generate_lease: false,
|
|
39
|
+
key_bits: 0,
|
|
40
|
+
key_type: 'rsa',
|
|
41
|
+
key_usage: ['DigitalSignature', 'KeyAgreement', 'KeyEncipherment'],
|
|
42
|
+
max_ttl: 31536000,
|
|
43
|
+
no_store: false,
|
|
44
|
+
require_cn: true,
|
|
45
|
+
server_flag: true,
|
|
46
|
+
signature_bits: 0,
|
|
47
|
+
ttl: 2592000,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const same = (live: Record<string, unknown>, props: BaoPkiRoleProps = PROPS) =>
|
|
51
|
+
matches(attributesOf(props, live), props);
|
|
52
|
+
|
|
53
|
+
describe('pki role — the six added fields', () => {
|
|
54
|
+
it("still plans noop for a role that carries OpenBao's defaults", () => {
|
|
55
|
+
assert.equal(same(LIVE), true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('plans update when live differs on any of them — the write would reset it', () => {
|
|
59
|
+
const drifted: [string, unknown][] = [
|
|
60
|
+
['require_cn', false],
|
|
61
|
+
['enforce_hostnames', false],
|
|
62
|
+
['key_usage', ['DigitalSignature']],
|
|
63
|
+
['allowed_domains_template', true],
|
|
64
|
+
['no_store', true],
|
|
65
|
+
['generate_lease', true],
|
|
66
|
+
];
|
|
67
|
+
for (const [field, value] of drifted) {
|
|
68
|
+
assert.equal(same({ ...LIVE, [field]: value }), false, field);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('reads key_usage as a set, case-folded, like ext_key_usage', () => {
|
|
73
|
+
const shuffled = {
|
|
74
|
+
...LIVE,
|
|
75
|
+
key_usage: ['keyencipherment', 'DigitalSignature', 'KeyAgreement'],
|
|
76
|
+
};
|
|
77
|
+
assert.equal(same(shuffled), true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('matches declared values once live carries them', () => {
|
|
81
|
+
const props = { ...PROPS, keyUsage: ['DigitalSignature'], noStore: true, requireCn: false };
|
|
82
|
+
const live = { ...LIVE, key_usage: ['DigitalSignature'], no_store: true, require_cn: false };
|
|
83
|
+
assert.equal(same(live, props), true);
|
|
84
|
+
assert.equal(same(LIVE, props), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("sends every one of them, with OpenBao's defaults when omitted", () => {
|
|
88
|
+
const body = writeBody(PROPS);
|
|
89
|
+
assert.equal(body['require_cn'], 'true');
|
|
90
|
+
assert.equal(body['enforce_hostnames'], 'true');
|
|
91
|
+
assert.equal(body['key_usage'], 'DigitalSignature,KeyAgreement,KeyEncipherment');
|
|
92
|
+
assert.equal(body['allowed_domains_template'], 'false');
|
|
93
|
+
assert.equal(body['no_store'], 'false');
|
|
94
|
+
assert.equal(body['generate_lease'], 'false');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('sends an empty keyUsage as an empty string, which the server reads as no usages', () => {
|
|
98
|
+
assert.equal(writeBody({ ...PROPS, keyUsage: [] })['key_usage'], '');
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('pki role problems', () => {
|
|
103
|
+
it('passes a sound declaration', () => {
|
|
104
|
+
assert.deepEqual(problems(PROPS), []);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('refuses noStore with generateLease, empty domains and bad durations', () => {
|
|
108
|
+
assert.match(problems({ ...PROPS, generateLease: true, noStore: true }).join(), /noStore/);
|
|
109
|
+
assert.match(problems({ ...PROPS, allowedDomains: [] }).join(), /allowedDomains is empty/);
|
|
110
|
+
assert.match(problems({ ...PROPS, ttl: '4h30m' }).join(), /ttl=4h30m/);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { mountPath, parseDuration } from './mount-form.ts';
|
|
2
|
+
import {
|
|
3
|
+
type BaoPkiRoleCanonical,
|
|
4
|
+
DEFAULT_KEY_USAGE,
|
|
5
|
+
canonicalFromLive,
|
|
6
|
+
canonicalFromProps,
|
|
7
|
+
digestOf,
|
|
8
|
+
} from './pki-role-canonical.ts';
|
|
9
|
+
|
|
10
|
+
export type { BaoPkiRoleCanonical };
|
|
3
11
|
|
|
4
12
|
/** ★ MEASURED: the OpenAPI enum for `key_type` is exactly these four, lowercase. */
|
|
5
13
|
export type BaoPkiKeyType = 'any' | 'ec' | 'ed25519' | 'rsa';
|
|
@@ -56,34 +64,29 @@ export interface BaoPkiRoleProps {
|
|
|
56
64
|
cnValidations?: readonly BaoPkiCnValidation[];
|
|
57
65
|
/** Signature hash bits; `0` auto-detects from key length. One live role pins 256. */
|
|
58
66
|
signatureBits?: number;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
/** ⚠️ `-1` means the duration prop did not parse — reconcile refuses rather than writing it. */
|
|
83
|
-
maxTtlSeconds: number;
|
|
84
|
-
serverFlag: boolean;
|
|
85
|
-
signatureBits: number;
|
|
86
|
-
ttlSeconds: number;
|
|
67
|
+
/**
|
|
68
|
+
* ★ THE SIX BELOW (added 2026-09-21) default to OpenBao's OWN values, not closed ones — a role this
|
|
69
|
+
* resource wrote before they were props carries exactly those, so it still plans `noop`. The
|
|
70
|
+
* write already sent them implicitly; see the header of pki-role-canonical.ts.
|
|
71
|
+
*/
|
|
72
|
+
/** A request must carry a common_name. Default true. */
|
|
73
|
+
requireCn?: boolean;
|
|
74
|
+
/** Only valid host names in the CN and DNS SANs. Default true. */
|
|
75
|
+
enforceHostnames?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Key usages minus the `KeyUsage` prefix. Default `DigitalSignature, KeyAgreement,
|
|
78
|
+
* KeyEncipherment`. ⚠️ `[]` is a real value — it removes every key usage — and is sent as such.
|
|
79
|
+
*/
|
|
80
|
+
keyUsage?: readonly string[];
|
|
81
|
+
/** Allowed domains may use identity templates (`{{identity.entity.name}}`). Default false. */
|
|
82
|
+
allowedDomainsTemplate?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* Do not store issued certificates. Default false. ⛔ They can then be neither listed nor
|
|
85
|
+
* revoked. ⚠️ Implies `generateLease: false`; declaring both true is refused (see problems).
|
|
86
|
+
*/
|
|
87
|
+
noStore?: boolean;
|
|
88
|
+
/** Attach an OpenBao lease to every issued certificate. Default false — leases slow startup. */
|
|
89
|
+
generateLease?: boolean;
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
export interface BaoPkiRoleAttributes extends BaoPkiRoleCanonical {
|
|
@@ -96,84 +99,6 @@ export interface BaoPkiRoleAttributes extends BaoPkiRoleCanonical {
|
|
|
96
99
|
digest: string;
|
|
97
100
|
}
|
|
98
101
|
|
|
99
|
-
const sorted = (values: readonly string[]) => [...values].sort();
|
|
100
|
-
|
|
101
|
-
/** ⚠️ Case-folded for the COMPARE only; the write keeps the author's casing.
|
|
102
|
-
* Why, and what was never measured, is in the pki-role.ts header. */
|
|
103
|
-
const foldedUsages = (values: readonly string[]) => sorted(values.map((v) => v.toLowerCase()));
|
|
104
|
-
|
|
105
|
-
/** The one literal that fixes key order for both sides of the diff. */
|
|
106
|
-
const canonical = (input: BaoPkiRoleCanonical): BaoPkiRoleCanonical => ({
|
|
107
|
-
allowBareDomains: input.allowBareDomains,
|
|
108
|
-
allowGlobDomains: input.allowGlobDomains,
|
|
109
|
-
allowIpSans: input.allowIpSans,
|
|
110
|
-
allowLocalhost: input.allowLocalhost,
|
|
111
|
-
allowSubdomains: input.allowSubdomains,
|
|
112
|
-
allowWildcardCertificates: input.allowWildcardCertificates,
|
|
113
|
-
allowedDomains: sorted(input.allowedDomains),
|
|
114
|
-
clientFlag: input.clientFlag,
|
|
115
|
-
cnValidations: sorted(input.cnValidations),
|
|
116
|
-
extKeyUsage: foldedUsages(input.extKeyUsage),
|
|
117
|
-
keyBits: input.keyBits,
|
|
118
|
-
keyType: input.keyType,
|
|
119
|
-
maxTtlSeconds: input.maxTtlSeconds,
|
|
120
|
-
serverFlag: input.serverFlag,
|
|
121
|
-
signatureBits: input.signatureBits,
|
|
122
|
-
ttlSeconds: input.ttlSeconds,
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const seconds = (text: string) => parseDuration(text) ?? -1;
|
|
126
|
-
|
|
127
|
-
export const canonicalFromProps = (props: BaoPkiRoleProps): BaoPkiRoleCanonical =>
|
|
128
|
-
canonical({
|
|
129
|
-
allowBareDomains: props.allowBareDomains ?? false,
|
|
130
|
-
allowGlobDomains: props.allowGlobDomains ?? false,
|
|
131
|
-
allowIpSans: props.allowIpSans ?? false,
|
|
132
|
-
allowLocalhost: props.allowLocalhost ?? false,
|
|
133
|
-
allowSubdomains: props.allowSubdomains ?? false,
|
|
134
|
-
allowWildcardCertificates: props.allowWildcardCertificates ?? false,
|
|
135
|
-
allowedDomains: props.allowedDomains,
|
|
136
|
-
clientFlag: props.clientFlag ?? true,
|
|
137
|
-
cnValidations: props.cnValidations ?? ['hostname'],
|
|
138
|
-
extKeyUsage: props.extKeyUsage ?? [],
|
|
139
|
-
keyBits: props.keyBits ?? 0,
|
|
140
|
-
keyType: props.keyType ?? 'rsa',
|
|
141
|
-
maxTtlSeconds: seconds(props.maxTtl),
|
|
142
|
-
serverFlag: props.serverFlag ?? true,
|
|
143
|
-
signatureBits: props.signatureBits ?? 0,
|
|
144
|
-
ttlSeconds: seconds(props.ttl),
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
const bool = (value: unknown, fallback: boolean) => (typeof value === 'boolean' ? value : fallback);
|
|
148
|
-
|
|
149
|
-
const int = (value: unknown, fallback: number) =>
|
|
150
|
-
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
151
|
-
|
|
152
|
-
const list = (value: unknown): readonly string[] =>
|
|
153
|
-
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
154
|
-
|
|
155
|
-
const str = (value: unknown, fallback: string) => (typeof value === 'string' ? value : fallback);
|
|
156
|
-
|
|
157
|
-
export const canonicalFromLive = (live: Record<string, unknown>): BaoPkiRoleCanonical =>
|
|
158
|
-
canonical({
|
|
159
|
-
allowBareDomains: bool(live['allow_bare_domains'], false),
|
|
160
|
-
allowGlobDomains: bool(live['allow_glob_domains'], false),
|
|
161
|
-
allowIpSans: bool(live['allow_ip_sans'], false),
|
|
162
|
-
allowLocalhost: bool(live['allow_localhost'], false),
|
|
163
|
-
allowSubdomains: bool(live['allow_subdomains'], false),
|
|
164
|
-
allowWildcardCertificates: bool(live['allow_wildcard_certificates'], false),
|
|
165
|
-
allowedDomains: list(live['allowed_domains']),
|
|
166
|
-
clientFlag: bool(live['client_flag'], true),
|
|
167
|
-
cnValidations: list(live['cn_validations']),
|
|
168
|
-
extKeyUsage: list(live['ext_key_usage']),
|
|
169
|
-
keyBits: int(live['key_bits'], 0),
|
|
170
|
-
keyType: str(live['key_type'], 'rsa'),
|
|
171
|
-
maxTtlSeconds: ttlSeconds(live['max_ttl']) ?? -1,
|
|
172
|
-
serverFlag: bool(live['server_flag'], true),
|
|
173
|
-
signatureBits: int(live['signature_bits'], 0),
|
|
174
|
-
ttlSeconds: ttlSeconds(live['ttl']) ?? -1,
|
|
175
|
-
});
|
|
176
|
-
|
|
177
102
|
/** ⚠️ Same rendering auth-role-form.ts uses, so `720h` and `2592000` read alike in a plan. */
|
|
178
103
|
const ttlText = (value: number) => {
|
|
179
104
|
if (value < 0) return '';
|
|
@@ -183,8 +108,6 @@ const ttlText = (value: number) => {
|
|
|
183
108
|
return `${String(value)}s`;
|
|
184
109
|
};
|
|
185
110
|
|
|
186
|
-
export const digestOf = (form: BaoPkiRoleCanonical) => sha256(JSON.stringify(form));
|
|
187
|
-
|
|
188
111
|
export const rolePath = (props: { name: string; mount?: string }) =>
|
|
189
112
|
`${mountPath(props.mount ?? 'pki')}/roles/${props.name}`;
|
|
190
113
|
|
|
@@ -216,6 +139,10 @@ export const attributesOf = (
|
|
|
216
139
|
* ★ `ext_key_usage` is OMITTED when empty rather than sent as `ext_key_usage=`. The write
|
|
217
140
|
* replaces the whole role, so an absent field already lands on the `[]` default, and
|
|
218
141
|
* omitting sidesteps how an empty value would be encoded.
|
|
142
|
+
* ⚠️ `key_usage` CANNOT BE OMITTED THAT WAY — its default is NOT empty — so it is always sent, and
|
|
143
|
+
* an empty declaration goes over as `""`. That encoding is now READ, not guessed: for a
|
|
144
|
+
* comma-string-slice field, go-secure-stdlib parseutil.ParseCommaStringSlice (:389-392)
|
|
145
|
+
* answers `""` with an empty list.
|
|
219
146
|
*
|
|
220
147
|
* ★ THE BODY FOR `PUT <mount>/roles/<name>`, ALL STRINGS — the same `k=v` pairs `bao write` sent.
|
|
221
148
|
*/
|
|
@@ -237,6 +164,12 @@ export const writeBody = (props: BaoPkiRoleProps): Record<string, string> => {
|
|
|
237
164
|
signature_bits: String(form.signatureBits),
|
|
238
165
|
ttl: props.ttl,
|
|
239
166
|
max_ttl: props.maxTtl,
|
|
167
|
+
require_cn: String(form.requireCn),
|
|
168
|
+
enforce_hostnames: String(form.enforceHostnames),
|
|
169
|
+
key_usage: (props.keyUsage ?? DEFAULT_KEY_USAGE).join(','),
|
|
170
|
+
allowed_domains_template: String(form.allowedDomainsTemplate),
|
|
171
|
+
no_store: String(form.noStore),
|
|
172
|
+
generate_lease: String(form.generateLease),
|
|
240
173
|
};
|
|
241
174
|
const usages = props.extKeyUsage ?? [];
|
|
242
175
|
if (usages.length > 0) body['ext_key_usage'] = usages.join(',');
|
|
@@ -246,3 +179,36 @@ export const writeBody = (props: BaoPkiRoleProps): Record<string, string> => {
|
|
|
246
179
|
/** True when live already matches the declaration on every managed field. */
|
|
247
180
|
export const matches = (attributes: BaoPkiRoleAttributes, props: BaoPkiRoleProps) =>
|
|
248
181
|
attributes.digest === digestOf(canonicalFromProps(props));
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Declarations reconcile must refuse rather than write. diff routes any of these to `update` so the
|
|
185
|
+
* refusal is seen on the next deploy, never hidden behind a `noop`.
|
|
186
|
+
*
|
|
187
|
+
* ⚠️ `noStore` WITH `generateLease` IS NOT AN ERROR ON THE SERVER — pathRoleCreate stores
|
|
188
|
+
* generate_lease false and adds a warning (path_roles.go:1250-1256) — so the role would read back
|
|
189
|
+
* different from its declaration and plan `update` forever. Refused here instead.
|
|
190
|
+
*/
|
|
191
|
+
export const problems = (props: BaoPkiRoleProps): readonly string[] => {
|
|
192
|
+
const found: string[] = [];
|
|
193
|
+
if (props.allowedDomains.length === 0) {
|
|
194
|
+
found.push(
|
|
195
|
+
'allowedDomains is empty. With allow_any_name false that role can issue no certificate at all.',
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
const bad = [
|
|
199
|
+
['ttl', props.ttl],
|
|
200
|
+
['maxTtl', props.maxTtl],
|
|
201
|
+
].filter(([, text]) => parseDuration(text ?? '') === undefined);
|
|
202
|
+
if (bad.length > 0) {
|
|
203
|
+
found.push(
|
|
204
|
+
`${bad.map(([k, v]) => `${k ?? ''}=${v ?? ''}`).join(', ')} is not a duration OpenBao ` +
|
|
205
|
+
'parses (expect 30m, 720h, 8760h or 0).',
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (props.noStore === true && props.generateLease === true) {
|
|
209
|
+
found.push(
|
|
210
|
+
'noStore and generateLease are both true; OpenBao keeps no_store and drops the lease.',
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return found;
|
|
214
|
+
};
|