@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `token_*` fields every auth role shares — read once here for Bao.JwtRole and
|
|
3
|
+
* Bao.KubernetesRole, so the two cannot disagree about what a token TTL is.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ FROM openbao v2.6.2 sdk/helper/tokenutil/tokenutil.go:
|
|
6
|
+
* · TokenFields (:80-170) — `token_policies`, `token_bound_cidrs` are comma-string slices;
|
|
7
|
+
* `token_ttl`, `token_max_ttl`, `token_explicit_max_ttl`, `token_period` are duration-seconds;
|
|
8
|
+
* `token_no_default_policy` bool; `token_num_uses` int; `token_type` a string.
|
|
9
|
+
* · ParseTokenFields (:176-262) — EVERY field is `GetOk`: an omitted field KEEPS its stored value.
|
|
10
|
+
* So an update is a merge, and the only way to clear a stale value is to send the default.
|
|
11
|
+
* That is why every field below is sent on every write, resolved to its default.
|
|
12
|
+
* · PopulateTokenData (:265-284) — the read answers seconds for durations, `[]` for empty lists,
|
|
13
|
+
* and `token_type` as a word (sdk/logical/token.go: `default`, `service`, `batch`).
|
|
14
|
+
* ⛔ NO SECRET HERE: policy names, CIDRs, TTLs.
|
|
15
|
+
*/
|
|
16
|
+
import { parseDuration, ttlSeconds } from './mount-form.ts';
|
|
17
|
+
|
|
18
|
+
export type BaoTokenType = 'batch' | 'default' | 'service';
|
|
19
|
+
|
|
20
|
+
export interface BaoTokenProps {
|
|
21
|
+
/** Policies on tokens this role issues. Names only. */
|
|
22
|
+
tokenPolicies: readonly string[];
|
|
23
|
+
/** Token TTL (`15m`). Default `0` — the mount's default. */
|
|
24
|
+
tokenTtl?: string;
|
|
25
|
+
/** Token max TTL (`1h`). Default `0` — the mount's max. */
|
|
26
|
+
tokenMaxTtl?: string;
|
|
27
|
+
/** Hard ceiling that renewal cannot pass. Default `0` (none). */
|
|
28
|
+
tokenExplicitMaxTtl?: string;
|
|
29
|
+
/** Periodic tokens renew forever within this period. Default `0` (not periodic). */
|
|
30
|
+
tokenPeriod?: string;
|
|
31
|
+
/** Leave the `default` policy off. Default false. */
|
|
32
|
+
tokenNoDefaultPolicy?: boolean;
|
|
33
|
+
/** CIDRs a token may be USED from. Default none. */
|
|
34
|
+
tokenBoundCidrs?: readonly string[];
|
|
35
|
+
/** Uses before the token dies; `0` is unlimited. Default 0. */
|
|
36
|
+
tokenNumUses?: number;
|
|
37
|
+
/** Default `default` — the mount decides, which means service tokens unless it says otherwise. */
|
|
38
|
+
tokenType?: BaoTokenType;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The token fields as seconds and sorted sets — both sides of a diff are built by `tokenForm`. */
|
|
42
|
+
export interface BaoTokenForm {
|
|
43
|
+
tokenBoundCidrs: readonly string[];
|
|
44
|
+
tokenExplicitMaxTtlSeconds: number;
|
|
45
|
+
tokenMaxTtlSeconds: number;
|
|
46
|
+
tokenNoDefaultPolicy: boolean;
|
|
47
|
+
tokenNumUses: number;
|
|
48
|
+
tokenPeriodSeconds: number;
|
|
49
|
+
tokenPolicies: readonly string[];
|
|
50
|
+
tokenTtlSeconds: number;
|
|
51
|
+
tokenType: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sortedSet = (values: readonly string[]) => [...new Set(values)].sort();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⚠️ A HOST CIDR READS BACK BARE. token_bound_cidrs is stored as sockaddr values, and a `/32` (or
|
|
58
|
+
* `/128`) host address renders without its mask. REASONED from go-sockaddr's String(), not
|
|
59
|
+
* measured — so the mask is dropped on BOTH sides, which can only hide a spelling difference.
|
|
60
|
+
*/
|
|
61
|
+
const cidr = (value: string) => value.trim().replace(/\/(32|128)$/, '');
|
|
62
|
+
|
|
63
|
+
const strings = (value: unknown): readonly string[] =>
|
|
64
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
65
|
+
|
|
66
|
+
const seconds = (text: string | undefined) => parseDuration(text ?? '0') ?? -1;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The token fields of an already-normalised form, in one fixed key order — what a role family's own
|
|
70
|
+
* canonical literal spreads, so its digest cannot depend on how the object was assembled.
|
|
71
|
+
*/
|
|
72
|
+
export const tokenFields = (input: BaoTokenForm): BaoTokenForm => ({
|
|
73
|
+
tokenBoundCidrs: input.tokenBoundCidrs,
|
|
74
|
+
tokenExplicitMaxTtlSeconds: input.tokenExplicitMaxTtlSeconds,
|
|
75
|
+
tokenMaxTtlSeconds: input.tokenMaxTtlSeconds,
|
|
76
|
+
tokenNoDefaultPolicy: input.tokenNoDefaultPolicy,
|
|
77
|
+
tokenNumUses: input.tokenNumUses,
|
|
78
|
+
tokenPeriodSeconds: input.tokenPeriodSeconds,
|
|
79
|
+
tokenPolicies: input.tokenPolicies,
|
|
80
|
+
tokenTtlSeconds: input.tokenTtlSeconds,
|
|
81
|
+
tokenType: input.tokenType,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The key order is fixed here, once, for both sides. */
|
|
85
|
+
const tokenForm = (input: BaoTokenForm): BaoTokenForm => ({
|
|
86
|
+
tokenBoundCidrs: sortedSet(input.tokenBoundCidrs.map(cidr)),
|
|
87
|
+
tokenExplicitMaxTtlSeconds: input.tokenExplicitMaxTtlSeconds,
|
|
88
|
+
tokenMaxTtlSeconds: input.tokenMaxTtlSeconds,
|
|
89
|
+
tokenNoDefaultPolicy: input.tokenNoDefaultPolicy,
|
|
90
|
+
tokenNumUses: input.tokenNumUses,
|
|
91
|
+
tokenPeriodSeconds: input.tokenPeriodSeconds,
|
|
92
|
+
tokenPolicies: sortedSet(input.tokenPolicies),
|
|
93
|
+
tokenTtlSeconds: input.tokenTtlSeconds,
|
|
94
|
+
tokenType: input.tokenType,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const tokenFormOfProps = (props: BaoTokenProps): BaoTokenForm =>
|
|
98
|
+
tokenForm({
|
|
99
|
+
tokenBoundCidrs: props.tokenBoundCidrs ?? [],
|
|
100
|
+
tokenExplicitMaxTtlSeconds: seconds(props.tokenExplicitMaxTtl),
|
|
101
|
+
tokenMaxTtlSeconds: seconds(props.tokenMaxTtl),
|
|
102
|
+
tokenNoDefaultPolicy: props.tokenNoDefaultPolicy ?? false,
|
|
103
|
+
tokenNumUses: props.tokenNumUses ?? 0,
|
|
104
|
+
tokenPeriodSeconds: seconds(props.tokenPeriod),
|
|
105
|
+
tokenPolicies: props.tokenPolicies,
|
|
106
|
+
tokenTtlSeconds: seconds(props.tokenTtl),
|
|
107
|
+
tokenType: props.tokenType ?? 'default',
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export const tokenFormOfLive = (live: Record<string, unknown>): BaoTokenForm =>
|
|
111
|
+
tokenForm({
|
|
112
|
+
tokenBoundCidrs: strings(live['token_bound_cidrs']),
|
|
113
|
+
tokenExplicitMaxTtlSeconds: ttlSeconds(live['token_explicit_max_ttl']) ?? -1,
|
|
114
|
+
tokenMaxTtlSeconds: ttlSeconds(live['token_max_ttl']) ?? -1,
|
|
115
|
+
tokenNoDefaultPolicy: live['token_no_default_policy'] === true,
|
|
116
|
+
tokenNumUses: typeof live['token_num_uses'] === 'number' ? live['token_num_uses'] : -1,
|
|
117
|
+
tokenPeriodSeconds: ttlSeconds(live['token_period']) ?? -1,
|
|
118
|
+
tokenPolicies: strings(live['token_policies']),
|
|
119
|
+
tokenTtlSeconds: ttlSeconds(live['token_ttl']) ?? -1,
|
|
120
|
+
tokenType: typeof live['token_type'] === 'string' ? live['token_type'] : '',
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The token half of a role write — every field, defaults included (the merge ⚠️ above). Durations
|
|
125
|
+
* go over as the author's own strings; lists as JSON arrays, which a comma-string-slice accepts.
|
|
126
|
+
*/
|
|
127
|
+
export const tokenBody = (props: BaoTokenProps): Record<string, unknown> => ({
|
|
128
|
+
token_bound_cidrs: [...(props.tokenBoundCidrs ?? [])],
|
|
129
|
+
token_explicit_max_ttl: props.tokenExplicitMaxTtl ?? '0',
|
|
130
|
+
token_max_ttl: props.tokenMaxTtl ?? '0',
|
|
131
|
+
token_no_default_policy: props.tokenNoDefaultPolicy ?? false,
|
|
132
|
+
token_num_uses: props.tokenNumUses ?? 0,
|
|
133
|
+
token_period: props.tokenPeriod ?? '0',
|
|
134
|
+
token_policies: [...props.tokenPolicies],
|
|
135
|
+
token_ttl: props.tokenTtl ?? '0',
|
|
136
|
+
token_type: props.tokenType ?? 'default',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/** Refusals the token half can know without a server. */
|
|
140
|
+
export const tokenProblems = (props: BaoTokenProps): readonly string[] => {
|
|
141
|
+
const found: string[] = [];
|
|
142
|
+
const durations = {
|
|
143
|
+
tokenExplicitMaxTtl: props.tokenExplicitMaxTtl,
|
|
144
|
+
tokenMaxTtl: props.tokenMaxTtl,
|
|
145
|
+
tokenPeriod: props.tokenPeriod,
|
|
146
|
+
tokenTtl: props.tokenTtl,
|
|
147
|
+
};
|
|
148
|
+
for (const [field, text] of Object.entries(durations)) {
|
|
149
|
+
if (seconds(text) < 0) found.push(`${field}=${text ?? ''} is not a duration (0 or <n>s|m|h|d)`);
|
|
150
|
+
}
|
|
151
|
+
const ttl = seconds(props.tokenTtl);
|
|
152
|
+
const max = seconds(props.tokenMaxTtl);
|
|
153
|
+
if (ttl > 0 && max > 0 && ttl > max) found.push('tokenTtl is longer than tokenMaxTtl');
|
|
154
|
+
if (
|
|
155
|
+
props.tokenType === 'batch' &&
|
|
156
|
+
(seconds(props.tokenPeriod) > 0 || (props.tokenNumUses ?? 0) > 0)
|
|
157
|
+
) {
|
|
158
|
+
found.push('a batch token cannot be periodic or use-limited (tokenutil.go:234-241)');
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assertBaoIdentity against a fake. The parts worth pinning: health goes out with NO token and NO
|
|
3
|
+
* namespace header (root-only API), a mismatch refuses before anything else is asked, and a
|
|
4
|
+
* missing namespace is a refusal rather than a quiet fall-through to root.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
BaoIdentityError,
|
|
10
|
+
HEALTH_PATH,
|
|
11
|
+
NAMESPACE_PROBE_PATH,
|
|
12
|
+
assertBaoIdentity,
|
|
13
|
+
assertBaoIdentityEffect,
|
|
14
|
+
} from './bao-identity.ts';
|
|
15
|
+
import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
|
|
16
|
+
|
|
17
|
+
const HEALTHY = {
|
|
18
|
+
cluster_id: '00000000-0000-4000-8000-000000000001',
|
|
19
|
+
cluster_name: 'vault-example',
|
|
20
|
+
initialized: true,
|
|
21
|
+
sealed: false,
|
|
22
|
+
standby: false,
|
|
23
|
+
version: '2.6.2',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const routes =
|
|
27
|
+
(health: Reply, probe: Reply = { json: { data: { auth: {}, secret: {} } }, status: 200 }) =>
|
|
28
|
+
(seen: Seen) =>
|
|
29
|
+
seen.path.startsWith('/v1/sys/health') ? health : probe;
|
|
30
|
+
|
|
31
|
+
const EXPECT = { clusterName: 'vault-example', namespace: 'team-a' };
|
|
32
|
+
const envOf = (address: string) => ({
|
|
33
|
+
BAO_ADDR: address,
|
|
34
|
+
BAO_NAMESPACE: 'team-a',
|
|
35
|
+
BAO_TOKEN: 'placeholder-token',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const failureOf = async (promise: Promise<unknown>) => {
|
|
39
|
+
const error = await promise;
|
|
40
|
+
assert.ok(error instanceof BaoIdentityError);
|
|
41
|
+
assert.match(error.message, /^REFUSING: /);
|
|
42
|
+
return error;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
describe('assertBaoIdentity', () => {
|
|
46
|
+
it('passes a matching vault, sending no token anywhere and no namespace to health', async () => {
|
|
47
|
+
await withFake(routes({ json: HEALTHY, status: 200 }), async (bao) => {
|
|
48
|
+
const identity = await run(envOf(bao.address), assertBaoIdentityEffect(EXPECT));
|
|
49
|
+
assert.equal(identity.clusterName, 'vault-example');
|
|
50
|
+
assert.equal(identity.namespace, 'team-a');
|
|
51
|
+
assert.equal(identity.version, '2.6.2');
|
|
52
|
+
const [health, probe] = bao.seen;
|
|
53
|
+
assert.equal(health?.path, `/v1/${HEALTH_PATH}`);
|
|
54
|
+
assert.equal(health?.headers.get('x-vault-namespace'), null);
|
|
55
|
+
assert.equal(probe?.path, `/v1/${NAMESPACE_PROBE_PATH}`);
|
|
56
|
+
assert.equal(probe?.headers.get('x-vault-namespace'), 'team-a');
|
|
57
|
+
for (const seen of bao.seen) assert.equal(seen.headers.get('x-vault-token'), null);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('refuses the wrong cluster and asks nothing more', async () => {
|
|
62
|
+
const other = { ...HEALTHY, cluster_name: 'vault-other' };
|
|
63
|
+
await withFake(routes({ json: other, status: 200 }), async (bao) => {
|
|
64
|
+
const error = await failureOf(
|
|
65
|
+
runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
|
|
66
|
+
);
|
|
67
|
+
assert.equal(error.reason, 'mismatch');
|
|
68
|
+
assert.match(error.message, /vault-other/);
|
|
69
|
+
assert.equal(bao.seen.length, 1);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('refuses a sealed vault as unconfirmed, because sealed hides cluster_name', async () => {
|
|
74
|
+
const sealed = { initialized: true, sealed: true, standby: true, version: '2.6.2' };
|
|
75
|
+
await withFake(routes({ json: sealed, status: 200 }), async (bao) => {
|
|
76
|
+
const error = await failureOf(
|
|
77
|
+
runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
|
|
78
|
+
);
|
|
79
|
+
assert.equal(error.reason, 'unconfirmed');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('refuses a BAO_NAMESPACE that is not the expected one without calling out', async () => {
|
|
84
|
+
await withFake(routes({ json: HEALTHY, status: 200 }), async (bao) => {
|
|
85
|
+
const env = { ...envOf(bao.address), BAO_NAMESPACE: '' };
|
|
86
|
+
const error = await failureOf(runFailure(env, assertBaoIdentityEffect(EXPECT)));
|
|
87
|
+
assert.equal(error.reason, 'mismatch');
|
|
88
|
+
assert.match(error.message, /`root`/);
|
|
89
|
+
assert.equal(bao.seen.length, 0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('refuses when the namespace does not exist on that cluster', async () => {
|
|
94
|
+
const missing = { json: { errors: ['namespace not found'] }, status: 404 };
|
|
95
|
+
await withFake(routes({ json: HEALTHY, status: 200 }, missing), async (bao) => {
|
|
96
|
+
const error = await failureOf(
|
|
97
|
+
runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
|
|
98
|
+
);
|
|
99
|
+
assert.equal(error.reason, 'mismatch');
|
|
100
|
+
assert.match(error.message, /no namespace `team-a`/);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips the probe for the root namespace, however it is spelled', async () => {
|
|
105
|
+
await withFake(routes({ json: HEALTHY, status: 200 }), async (bao) => {
|
|
106
|
+
const env = { BAO_ADDR: bao.address, BAO_NAMESPACE: 'root' };
|
|
107
|
+
await run(env, assertBaoIdentityEffect({ clusterName: 'vault-example', namespace: '/' }));
|
|
108
|
+
assert.equal(bao.seen.length, 1);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('refuses a 200 that is not an OpenBao health body', async () => {
|
|
113
|
+
await withFake(routes({ json: { ok: true }, status: 200 }), async (bao) => {
|
|
114
|
+
const error = await failureOf(
|
|
115
|
+
runFailure(envOf(bao.address), assertBaoIdentityEffect(EXPECT)),
|
|
116
|
+
);
|
|
117
|
+
assert.equal(error.reason, 'unconfirmed');
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('rejects the Promise form with the BaoIdentityError itself', async () => {
|
|
122
|
+
const other = { ...HEALTHY, cluster_name: 'vault-other' };
|
|
123
|
+
await withFake(routes({ json: other, status: 200 }), async (bao) => {
|
|
124
|
+
const error = await assertBaoIdentity({ ...EXPECT, env: envOf(bao.address) }).then(
|
|
125
|
+
() => undefined,
|
|
126
|
+
(rejection: unknown) => rejection,
|
|
127
|
+
);
|
|
128
|
+
await failureOf(Promise.resolve(error));
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse to plan against the wrong vault: assert the `cluster_name` and namespace a stack expects
|
|
3
|
+
* BEFORE any Bao.* resource reads or writes.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ WHY IT EXISTS. A `BAO_ADDR` left exported for one vault (or a `BAO_AGENT_ADDR`, which wins)
|
|
6
|
+
* would otherwise apply a stack written for another vault to it, and every family here would
|
|
7
|
+
* plan CREATEs against the wrong server with a clean conscience (the estate's site-config plan,
|
|
8
|
+
* "Identity checks on every live plan").
|
|
9
|
+
* A missing `BAO_NAMESPACE` does the same thing one level down: the stack lands in root.
|
|
10
|
+
*
|
|
11
|
+
* ★ `sys/health`, UNAUTHENTICATED, WITH NO TOKEN AND NO NAMESPACE HEADER. READ FROM openbao v2.6.2:
|
|
12
|
+
* · http/sys_health.go:143-165 — `cluster_name` and `cluster_id` are filled ONLY while unsealed,
|
|
13
|
+
* and the body is top-level JSON, not wrapped in `data`.
|
|
14
|
+
* · http/handler.go:108-124, :405-411 — `health` is a root-only API: a request carrying
|
|
15
|
+
* `X-Vault-Namespace` is answered 400 "operation unavailable in namespaces". So the header is
|
|
16
|
+
* stripped for this one call, whatever BAO_NAMESPACE says.
|
|
17
|
+
* · The query asks for 200 when sealed, uninitialised or standby (`sealedcode`, `uninitcode`,
|
|
18
|
+
* `standbyok`, :78-110), so the body is always there to read and the refusal can say WHICH.
|
|
19
|
+
* ⛔ NO TOKEN IS SENT. Until this check passes, the server has not even claimed to be ours, and a
|
|
20
|
+
* token sent to the wrong server is a token handed over.
|
|
21
|
+
* ⚠️ IT CATCHES A MISTARGET, NOT AN IMPOSTOR (review 2026-09-21). `cluster_name` is answered to
|
|
22
|
+
* anyone, unauthenticated, so a hostile server can echo the expected one; passing proves the
|
|
23
|
+
* address is configured right, never who answers. TLS to a trusted certificate is what
|
|
24
|
+
* authenticates the server — keep an https BAO_ADDR on anything that is not loopback or a socket.
|
|
25
|
+
* ★ THE NAMESPACE IS CHECKED TWICE. Locally first: the namespace every later call will send (the
|
|
26
|
+
* same bao-address.ts resolution) must BE the expected one. Then remotely: unauthenticated
|
|
27
|
+
* `sys/internal/ui/mounts` with that namespace header answers 200 when the namespace exists and
|
|
28
|
+
* 404 "namespace not found" when it does not (vault/request_handling.go:607-612; the path is in
|
|
29
|
+
* the system backend's Unauthenticated list, vault/logical_system.go:95). Only the status is read.
|
|
30
|
+
* REASONED FROM SOURCE, not measured against a live server.
|
|
31
|
+
* ⚠️ IT PROVES THE CLUSTER, NOT THE NODE. A standby answers with the cluster's name, which is the
|
|
32
|
+
* point: a standby forwards to the same vault.
|
|
33
|
+
*/
|
|
34
|
+
import * as Effect from 'effect/Effect';
|
|
35
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
36
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
37
|
+
import { type BaoEnvironment, resolveAddress } from './bao-address.ts';
|
|
38
|
+
import { BaoEnv, baoCall } from './bao-http.ts';
|
|
39
|
+
import { canonicalNamespace, envNamespace, namespaceLabel } from './bao-namespace.ts';
|
|
40
|
+
import type { BaoError } from './bao-status.ts';
|
|
41
|
+
|
|
42
|
+
/** `input` a blank expectation · `mismatch` the wrong vault · `unconfirmed` could not tell · `unreachable` no answer. */
|
|
43
|
+
export type BaoIdentityFailure = 'input' | 'mismatch' | 'unconfirmed' | 'unreachable';
|
|
44
|
+
|
|
45
|
+
/** ⛔ Every message starts `REFUSING:`, and none carries a token — only the address and names. */
|
|
46
|
+
export class BaoIdentityError extends Error {
|
|
47
|
+
constructor(
|
|
48
|
+
readonly reason: BaoIdentityFailure,
|
|
49
|
+
detail: string,
|
|
50
|
+
) {
|
|
51
|
+
super(`REFUSING: ${detail}`);
|
|
52
|
+
this.name = 'BaoIdentityError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BaoIdentityExpected {
|
|
57
|
+
/** The vault's `cluster_name` (server config `cluster_name`, or the one generated at init). */
|
|
58
|
+
readonly clusterName: string;
|
|
59
|
+
/** The namespace the stack targets: `team-a`, or `''` / `root` for the root namespace. */
|
|
60
|
+
readonly namespace: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What the vault said about itself once every check passed. */
|
|
64
|
+
export interface BaoIdentity {
|
|
65
|
+
/** The resolved address, without any token. */
|
|
66
|
+
readonly address: string;
|
|
67
|
+
readonly clusterName: string;
|
|
68
|
+
readonly clusterId: string;
|
|
69
|
+
readonly version: string;
|
|
70
|
+
/** Canonical: `''` is root. */
|
|
71
|
+
readonly namespace: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const HEALTH_PATH = 'sys/health?standbyok=true&sealedcode=200&uninitcode=200';
|
|
75
|
+
export const NAMESPACE_PROBE_PATH = 'sys/internal/ui/mounts';
|
|
76
|
+
|
|
77
|
+
const addressOf = (env: BaoEnvironment) => {
|
|
78
|
+
const address = resolveAddress(env);
|
|
79
|
+
return address.socket === undefined ? address.base : `unix://${address.socket}`;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const unreachable =
|
|
83
|
+
(address: string, what: string) =>
|
|
84
|
+
(error: BaoError): BaoIdentityError =>
|
|
85
|
+
new BaoIdentityError('unreachable', `${address} did not answer ${what}: ${error.message}`);
|
|
86
|
+
|
|
87
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
88
|
+
|
|
89
|
+
/** Read a health body into the one question asked of it. Pure, for the tests. */
|
|
90
|
+
export const checkHealth = (
|
|
91
|
+
address: string,
|
|
92
|
+
expectedCluster: string,
|
|
93
|
+
body: Record<string, unknown> | undefined,
|
|
94
|
+
): { readonly clusterId: string; readonly version: string } | BaoIdentityError => {
|
|
95
|
+
if (body === undefined || typeof body['sealed'] !== 'boolean') {
|
|
96
|
+
return new BaoIdentityError(
|
|
97
|
+
'unconfirmed',
|
|
98
|
+
`${address} answered sys/health, but not as OpenBao.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (body['initialized'] === false) {
|
|
102
|
+
return new BaoIdentityError('unconfirmed', `${address} is not initialised.`);
|
|
103
|
+
}
|
|
104
|
+
if (body['sealed'] === true) {
|
|
105
|
+
return new BaoIdentityError(
|
|
106
|
+
'unconfirmed',
|
|
107
|
+
`${address} is sealed, and a sealed vault does not report its cluster_name.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const clusterName = text(body['cluster_name']);
|
|
111
|
+
if (clusterName !== expectedCluster) {
|
|
112
|
+
return new BaoIdentityError(
|
|
113
|
+
'mismatch',
|
|
114
|
+
`${address} answers as cluster \`${clusterName || '(none)'}\`, but this stack expects ` +
|
|
115
|
+
`\`${expectedCluster}\`. Check BAO_ADDR and BAO_AGENT_ADDR (the agent address wins).`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return { clusterId: text(body['cluster_id']), version: text(body['version']) };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The check, as an Effect under BaoEnv (process.env unless provided). Fails with BaoIdentityError
|
|
123
|
+
* and nothing else.
|
|
124
|
+
*/
|
|
125
|
+
export const assertBaoIdentityEffect = (
|
|
126
|
+
expected: BaoIdentityExpected,
|
|
127
|
+
): Effect.Effect<BaoIdentity, BaoIdentityError, HttpClient.HttpClient> =>
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const env = yield* BaoEnv;
|
|
130
|
+
const address = addressOf(env);
|
|
131
|
+
const clusterName = expected.clusterName.trim();
|
|
132
|
+
if (clusterName === '') {
|
|
133
|
+
return yield* Effect.fail(
|
|
134
|
+
new BaoIdentityError('input', 'clusterName is empty, so there is nothing to compare.'),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
const namespace = canonicalNamespace(expected.namespace);
|
|
138
|
+
const sent = envNamespace(env);
|
|
139
|
+
if (sent !== namespace) {
|
|
140
|
+
return yield* Effect.fail(
|
|
141
|
+
new BaoIdentityError(
|
|
142
|
+
'mismatch',
|
|
143
|
+
`BAO_NAMESPACE resolves to \`${namespaceLabel(sent)}\`, but this stack expects ` +
|
|
144
|
+
`\`${namespaceLabel(namespace)}\`.`,
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const anonymous = { ...env, BAO_NAMESPACE: '', BAO_TOKEN: '' };
|
|
150
|
+
const body = yield* baoCall('read', 'GET', HEALTH_PATH).pipe(
|
|
151
|
+
Effect.provideService(BaoEnv, anonymous),
|
|
152
|
+
Effect.mapError(unreachable(address, 'sys/health')),
|
|
153
|
+
);
|
|
154
|
+
const health = checkHealth(address, clusterName, body);
|
|
155
|
+
if (health instanceof BaoIdentityError) return yield* Effect.fail(health);
|
|
156
|
+
|
|
157
|
+
if (namespace !== '') {
|
|
158
|
+
const scoped = { ...env, BAO_NAMESPACE: namespace, BAO_TOKEN: '' };
|
|
159
|
+
const probe = yield* baoCall('read', 'GET', NAMESPACE_PROBE_PATH).pipe(
|
|
160
|
+
Effect.provideService(BaoEnv, scoped),
|
|
161
|
+
Effect.mapError(unreachable(address, `the namespace probe for \`${namespace}\``)),
|
|
162
|
+
);
|
|
163
|
+
if (probe === undefined) {
|
|
164
|
+
return yield* Effect.fail(
|
|
165
|
+
new BaoIdentityError(
|
|
166
|
+
'mismatch',
|
|
167
|
+
`cluster \`${clusterName}\` at ${address} has no namespace \`${namespace}\`.`,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return { address, clusterName, namespace, ...health };
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `assertBaoIdentityEffect` for a plain script. `env` defaults to `process.env`, read at call time.
|
|
177
|
+
* ★ Rejects with the BaoIdentityError itself (Effect.runPromise, as in approle-login.ts).
|
|
178
|
+
*
|
|
179
|
+
* await assertBaoIdentity({ clusterName: 'vault-example', namespace: 'team-a' });
|
|
180
|
+
*/
|
|
181
|
+
export const assertBaoIdentity = (
|
|
182
|
+
input: BaoIdentityExpected & { readonly env?: BaoEnvironment },
|
|
183
|
+
): Promise<BaoIdentity> => {
|
|
184
|
+
const effect = assertBaoIdentityEffect(input);
|
|
185
|
+
const scoped =
|
|
186
|
+
input.env === undefined ? effect : Effect.provideService(effect, BaoEnv, input.env);
|
|
187
|
+
return Effect.runPromise(scoped.pipe(Effect.provide(FetchHttpClient.layer)));
|
|
188
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Namespace spellings, made comparable. `homeflare`, `/homeflare/` and `homeflare/` are one
|
|
3
|
+
* namespace to OpenBao, and `root` (or nothing) is the root namespace.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ FROM openbao v2.6.2: namespace.Canonicalize trims the leading slash and appends a trailing
|
|
6
|
+
* one, and ResolveNamespaceFromRequest (vault/namespace_store.go:1573-1598) re-routes a header
|
|
7
|
+
* that is literally `root/` to the root namespace. A namespace's own `namespace_path` in an API
|
|
8
|
+
* answer is that canonical form: `''` for root, `a/b/` below it (vault/login_mfa.go
|
|
9
|
+
* mfaConfigToMap and mfaLoginEnforcementConfigToMap).
|
|
10
|
+
*/
|
|
11
|
+
import { type BaoEnvironment, resolveAddress } from './bao-address.ts';
|
|
12
|
+
import { trimSlashes } from './mount-path.ts';
|
|
13
|
+
|
|
14
|
+
/** The bare name: no leading or trailing slash, and `''` for the root namespace. */
|
|
15
|
+
export const canonicalNamespace = (namespace: string | undefined): string => {
|
|
16
|
+
const bare = trimSlashes((namespace ?? '').trim());
|
|
17
|
+
return bare === 'root' ? '' : bare;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** OpenBao's `namespace_path` rendering of the same namespace: `''` or `a/b/`. */
|
|
21
|
+
export const namespacePath = (namespace: string | undefined): string => {
|
|
22
|
+
const bare = canonicalNamespace(namespace);
|
|
23
|
+
return bare === '' ? '' : `${bare}/`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The namespace every call under `env` targets, canonical. */
|
|
27
|
+
export const envNamespace = (env: BaoEnvironment): string =>
|
|
28
|
+
canonicalNamespace(resolveAddress(env).namespace);
|
|
29
|
+
|
|
30
|
+
/** How a namespace reads in a message: `root` rather than an empty pair of backticks. */
|
|
31
|
+
export const namespaceLabel = (namespace: string): string =>
|
|
32
|
+
namespace === '' ? 'root' : namespace;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters
|
|
3
|
-
* a write parses the duration text into seconds and re-marshals `policies`
|
|
4
|
-
* :199-210), a role never written is a 404, and a delete answers 204
|
|
2
|
+
* Bao.CloudflareRole's calls against a fake that behaves like the Cloudflare engine where it matters
|
|
3
|
+
* (fake-engines.ts): a write parses the duration text into seconds and re-marshals `policies`
|
|
4
|
+
* (path_roles.go:117-146, :199-210), a role never written is a 404, and a delete answers 204
|
|
5
|
+
* whether or not it existed.
|
|
5
6
|
*/
|
|
6
7
|
import assert from 'node:assert/strict';
|
|
7
8
|
import { describe, it } from 'node:test';
|
|
@@ -13,8 +14,8 @@ import {
|
|
|
13
14
|
readCloudflareRole,
|
|
14
15
|
writeCloudflareRole,
|
|
15
16
|
} from './cloudflare-role-wire.ts';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
17
|
+
import { run, runFailure, withFake } from './fake-bao.ts';
|
|
18
|
+
import { cloudflareRoles } from './fake-engines.ts';
|
|
18
19
|
|
|
19
20
|
const PROPS: BaoCloudflareRoleProps = {
|
|
20
21
|
description: 'Read DNS records on one zone. (zone: example.com)',
|
|
@@ -34,36 +35,9 @@ const PROPS: BaoCloudflareRoleProps = {
|
|
|
34
35
|
|
|
35
36
|
const PATH = '/v1/cloudflare-acme-dns/roles/example-com-dns-read';
|
|
36
37
|
|
|
37
|
-
/** The engine's role store, reduced to what these calls can observe. */
|
|
38
|
-
const roleStore = () => {
|
|
39
|
-
const roles = new Map<string, Record<string, unknown>>();
|
|
40
|
-
return (seen: Seen): Reply => {
|
|
41
|
-
if (seen.method === 'PUT') {
|
|
42
|
-
const body = JSON.parse(seen.body) as Record<string, string>;
|
|
43
|
-
roles.set(seen.path, {
|
|
44
|
-
description: body['description'],
|
|
45
|
-
max_ttl: parseDuration(body['max_ttl'] ?? ''),
|
|
46
|
-
name: seen.path.split('/').at(-1),
|
|
47
|
-
// ★ Re-marshalled, as json.Marshal of the stored structs would be.
|
|
48
|
-
policies: JSON.stringify(JSON.parse(body['policies'] ?? 'null')),
|
|
49
|
-
ttl: parseDuration(body['ttl'] ?? ''),
|
|
50
|
-
});
|
|
51
|
-
return { status: 204 };
|
|
52
|
-
}
|
|
53
|
-
if (seen.method === 'DELETE') {
|
|
54
|
-
roles.delete(seen.path);
|
|
55
|
-
return { status: 204 };
|
|
56
|
-
}
|
|
57
|
-
const role = roles.get(seen.path);
|
|
58
|
-
return role === undefined
|
|
59
|
-
? { json: { errors: [] }, status: 404 }
|
|
60
|
-
: { json: { data: role }, status: 200 };
|
|
61
|
-
};
|
|
62
|
-
};
|
|
63
|
-
|
|
64
38
|
describe('Cloudflare role wire', () => {
|
|
65
39
|
it('reads a role that was never written as absent', async () => {
|
|
66
|
-
await withFake(
|
|
40
|
+
await withFake(cloudflareRoles(), async (bao) => {
|
|
67
41
|
const env = { BAO_ADDR: bao.address };
|
|
68
42
|
assert.equal(await run(env, readCloudflareRole(PROPS.mount, PROPS.name)), undefined);
|
|
69
43
|
assert.deepEqual([bao.seen[0]?.method, bao.seen[0]?.path], ['GET', PATH]);
|
|
@@ -71,7 +45,7 @@ describe('Cloudflare role wire', () => {
|
|
|
71
45
|
});
|
|
72
46
|
|
|
73
47
|
it('writes the four fields and reads them back with no difference', async () => {
|
|
74
|
-
await withFake(
|
|
48
|
+
await withFake(cloudflareRoles(), async (bao) => {
|
|
75
49
|
const env = { BAO_ADDR: bao.address };
|
|
76
50
|
const { policies } = resolvePolicies(
|
|
77
51
|
PROPS.policies,
|
|
@@ -38,6 +38,13 @@ import {
|
|
|
38
38
|
readCloudflareRole,
|
|
39
39
|
writeCloudflareRole,
|
|
40
40
|
} from './cloudflare-role-wire.ts';
|
|
41
|
+
import {
|
|
42
|
+
declaredRolePath,
|
|
43
|
+
isMoved,
|
|
44
|
+
judgeMove,
|
|
45
|
+
refuseMovedUpdate,
|
|
46
|
+
triedRolePath,
|
|
47
|
+
} from './rename.ts';
|
|
41
48
|
|
|
42
49
|
export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
|
|
43
50
|
|
|
@@ -112,14 +119,24 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
112
119
|
* OpenBao UI changes what every future token can do while the digest in Postgres still
|
|
113
120
|
* says everything is fine.
|
|
114
121
|
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
* the
|
|
118
|
-
*
|
|
119
|
-
*
|
|
122
|
+
* ⛔ A NEW `mount` OR `name` IS A `replace`, DECIDED BEFORE ANY OTHER READ (rename.ts). Until
|
|
123
|
+
* 2026-09-21 this read the new path, found it absent and planned `update`: reconcile wrote
|
|
124
|
+
* the new role and the old one stayed live and mintable under no state record. ⛔ A move
|
|
125
|
+
* onto a role that already exists fails the plan (`judgeMove`).
|
|
126
|
+
* ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL MINTS for any token whose policy reaches its
|
|
127
|
+
* `creds/<name>`. Under `destroy` every consumer still minting from the old path fails at
|
|
128
|
+
* the delete, so move them in the same PR (REPLACE.md).
|
|
129
|
+
* ★ A STACK THAT PUTS mount AND name IN THE LOGICAL ID never reaches this branch
|
|
130
|
+
* (homeflare-openbao's declareCloudflareRoles does). There a rename is a new logical id,
|
|
131
|
+
* and the old id leaves the stack as an orphan delete, which `retain` keeps live.
|
|
120
132
|
*/
|
|
121
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
122
|
-
|
|
133
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
134
|
+
const tried = triedRolePath(output, olds, rolePath);
|
|
135
|
+
const declared = declaredRolePath(news, rolePath);
|
|
136
|
+
const move = yield* judgeMove('Bao.CloudflareRole', tried, declared, (path) => path);
|
|
137
|
+
if (output === undefined) return undefined;
|
|
138
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
139
|
+
if (!isResolved(news)) return undefined;
|
|
123
140
|
/**
|
|
124
141
|
* ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop — route it to reconcile,
|
|
125
142
|
* which says why (pki-role.ts has the same guard).
|
|
@@ -133,8 +150,13 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
133
150
|
: ({ action: 'update' } as const);
|
|
134
151
|
}),
|
|
135
152
|
|
|
136
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
153
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
137
154
|
const path = rolePath(news.mount, news.name);
|
|
155
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
156
|
+
const before = output === undefined ? path : rolePath(output.mount, output.name);
|
|
157
|
+
if (isMoved(before, path) === true) {
|
|
158
|
+
return yield* refuseMovedUpdate('Bao.CloudflareRole', before, path);
|
|
159
|
+
}
|
|
138
160
|
const refusal = refusalOf(news);
|
|
139
161
|
if (refusal !== undefined) {
|
|
140
162
|
return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { accountResource, bucketResource, zoneResource } from './cloudflare-group-scope.ts';
|
|
16
16
|
import type { AccountSpec, RoleTemplate, RolesConfig } from './cloudflare-roles-config.ts';
|
|
17
|
+
import { trimRuns } from './mount-path.ts';
|
|
17
18
|
|
|
18
19
|
/** A zone as the expansion needs it — what cfhf.py load_account listed (cfhf.py:134-146). */
|
|
19
20
|
export interface CloudflareZone {
|
|
@@ -73,11 +74,7 @@ export interface ExpandedRole {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
/** cfroles.py:18-20 — `example.com` → `homeflare-dev`. */
|
|
76
|
-
export const slug = (name: string) =>
|
|
77
|
-
name
|
|
78
|
-
.toLowerCase()
|
|
79
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
80
|
-
.replace(/^-+|-+$/g, '');
|
|
77
|
+
export const slug = (name: string) => trimRuns(name.toLowerCase().replace(/[^a-z0-9]+/g, '-'), '-');
|
|
81
78
|
|
|
82
79
|
/**
|
|
83
80
|
* cfroles.py:23-38. ⛔ THE `-internal` SUFFIX IS WHAT KEEPS TWO ZONES OF ONE NAME APART. Slugging
|