@homeflare/alchemy 0.5.0 → 0.7.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 +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -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 +24 -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-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- 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 +1518 -310
- package/dist/openbao/index.js.map +40 -17
- 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/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-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/rename.d.ts +106 -0
- package/dist/openbao/rename.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/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -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 +163 -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-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- 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/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -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
|
+
};
|
package/src/openbao/pki-role.ts
CHANGED
|
@@ -44,12 +44,13 @@ import * as Provider from 'alchemy/Provider';
|
|
|
44
44
|
import * as Effect from 'effect/Effect';
|
|
45
45
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
46
46
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
47
|
-
import { mountPath
|
|
47
|
+
import { mountPath } from './mount-form.ts';
|
|
48
48
|
import {
|
|
49
49
|
type BaoPkiRoleAttributes,
|
|
50
50
|
type BaoPkiRoleProps,
|
|
51
51
|
attributesOf,
|
|
52
52
|
matches,
|
|
53
|
+
problems,
|
|
53
54
|
rolePath,
|
|
54
55
|
writeBody,
|
|
55
56
|
} from './pki-role-form.ts';
|
|
@@ -75,12 +76,6 @@ const readRole = (props: BaoPkiRoleProps) =>
|
|
|
75
76
|
return attributesOf(props, live);
|
|
76
77
|
});
|
|
77
78
|
|
|
78
|
-
/** Duration props `bao write` must never see — reconcile turns these into a refusal. */
|
|
79
|
-
const badDurations = (props: BaoPkiRoleProps) =>
|
|
80
|
-
[['ttl', props.ttl] as const, ['maxTtl', props.maxTtl] as const].filter(
|
|
81
|
-
([, text]) => parseDuration(text) === undefined,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
79
|
export const BaoPkiRoleProvider = () =>
|
|
85
80
|
Provider.effect(
|
|
86
81
|
BaoPkiRole,
|
|
@@ -111,18 +106,20 @@ export const BaoPkiRoleProvider = () =>
|
|
|
111
106
|
* ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
|
|
112
107
|
* engine is a different object under a different CA, never an in-place edit —
|
|
113
108
|
* `replace`, so Alchemy creates the new one and retires the old.
|
|
109
|
+
* ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
|
|
110
|
+
* so a new `name` read nothing at the new path, planned `update`, wrote the new role,
|
|
111
|
+
* and left the old one issuing certificates under no state record at all.
|
|
114
112
|
*/
|
|
115
|
-
if (mountPath(news.mount ?? 'pki') !== output.mount)
|
|
113
|
+
if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
|
|
116
114
|
return { action: 'replace' } as const;
|
|
117
115
|
/**
|
|
118
116
|
* ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
|
|
119
117
|
* allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
|
|
120
118
|
* live role already in that state — and a noop there hides the refusal behind a
|
|
121
119
|
* clean plan until the next deploy. Route it to reconcile, which says why.
|
|
120
|
+
* (The full list is `problems` in pki-role-form.ts.)
|
|
122
121
|
*/
|
|
123
|
-
if (
|
|
124
|
-
return { action: 'update' } as const;
|
|
125
|
-
}
|
|
122
|
+
if (problems(news).length > 0) return { action: 'update' } as const;
|
|
126
123
|
const live = yield* readRole(news);
|
|
127
124
|
if (live === undefined) return { action: 'update' } as const;
|
|
128
125
|
return matches(live, news)
|
|
@@ -136,24 +133,12 @@ export const BaoPkiRoleProvider = () =>
|
|
|
136
133
|
* ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
|
|
137
134
|
* false on every live role, so a role with no allowed domains can issue NOTHING —
|
|
138
135
|
* and an unresolved or mistyped prop reads exactly like an author who meant it.
|
|
139
|
-
* policy.ts refuses an empty fragment directory for the same reason.
|
|
136
|
+
* policy.ts refuses an empty fragment directory for the same reason. The other
|
|
137
|
+
* refusals — an unparseable duration, noStore with generateLease — are in `problems`.
|
|
140
138
|
*/
|
|
141
|
-
|
|
142
|
-
return yield* Effect.die(
|
|
143
|
-
new Error(
|
|
144
|
-
`Bao.PkiRole ${path}: allowedDomains is empty. With allow_any_name false ` +
|
|
145
|
-
'that role can issue no certificate at all.',
|
|
146
|
-
),
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
const bad = badDurations(news);
|
|
139
|
+
const bad = problems(news);
|
|
150
140
|
if (bad.length > 0) {
|
|
151
|
-
return yield* Effect.die(
|
|
152
|
-
new Error(
|
|
153
|
-
`Bao.PkiRole ${path}: ${bad.map(([k, v]) => `${k}=${v}`).join(', ')} ` +
|
|
154
|
-
'is not a duration OpenBao parses (expect 30m, 720h, 8760h or 0).',
|
|
155
|
-
),
|
|
156
|
-
);
|
|
141
|
+
return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
|
|
157
142
|
}
|
|
158
143
|
const live = yield* readRole(news);
|
|
159
144
|
if (live === undefined || !matches(live, news)) {
|