@homeflare/alchemy 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/README.md +26 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/launchd/fake-runner.d.ts +36 -0
  5. package/dist/launchd/fake-runner.d.ts.map +1 -0
  6. package/dist/launchd/host-effect.d.ts +19 -0
  7. package/dist/launchd/host-effect.d.ts.map +1 -0
  8. package/dist/launchd/host-file-form.d.ts +24 -0
  9. package/dist/launchd/host-file-form.d.ts.map +1 -0
  10. package/dist/launchd/host-file-lifecycle.d.ts +29 -0
  11. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
  12. package/dist/launchd/host-file.d.ts +25 -0
  13. package/dist/launchd/host-file.d.ts.map +1 -0
  14. package/dist/launchd/host-lookup.d.ts +27 -0
  15. package/dist/launchd/host-lookup.d.ts.map +1 -0
  16. package/dist/launchd/index.d.ts +21 -0
  17. package/dist/launchd/index.d.ts.map +1 -0
  18. package/dist/launchd/index.js +969 -0
  19. package/dist/launchd/index.js.map +25 -0
  20. package/dist/launchd/job-form.d.ts +90 -0
  21. package/dist/launchd/job-form.d.ts.map +1 -0
  22. package/dist/launchd/job-lifecycle.d.ts +34 -0
  23. package/dist/launchd/job-lifecycle.d.ts.map +1 -0
  24. package/dist/launchd/job-preflight.d.ts +50 -0
  25. package/dist/launchd/job-preflight.d.ts.map +1 -0
  26. package/dist/launchd/job-validate.d.ts +25 -0
  27. package/dist/launchd/job-validate.d.ts.map +1 -0
  28. package/dist/launchd/job.d.ts +34 -0
  29. package/dist/launchd/job.d.ts.map +1 -0
  30. package/dist/launchd/launchctl.d.ts +62 -0
  31. package/dist/launchd/launchctl.d.ts.map +1 -0
  32. package/dist/launchd/local-runner.d.ts +7 -0
  33. package/dist/launchd/local-runner.d.ts.map +1 -0
  34. package/dist/launchd/plist.d.ts +41 -0
  35. package/dist/launchd/plist.d.ts.map +1 -0
  36. package/dist/launchd/providers.d.ts +15 -0
  37. package/dist/launchd/providers.d.ts.map +1 -0
  38. package/dist/launchd/runner.d.ts +91 -0
  39. package/dist/launchd/runner.d.ts.map +1 -0
  40. package/dist/launchd/secret-tripwire.d.ts +23 -0
  41. package/dist/launchd/secret-tripwire.d.ts.map +1 -0
  42. package/dist/openbao/approle-login-form.d.ts +72 -0
  43. package/dist/openbao/approle-login-form.d.ts.map +1 -0
  44. package/dist/openbao/approle-login-result.d.ts +39 -0
  45. package/dist/openbao/approle-login-result.d.ts.map +1 -0
  46. package/dist/openbao/approle-login.d.ts +77 -0
  47. package/dist/openbao/approle-login.d.ts.map +1 -0
  48. package/dist/openbao/auth-method-form.d.ts +7 -0
  49. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  51. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  52. package/dist/openbao/auth-method.d.ts +6 -0
  53. package/dist/openbao/auth-method.d.ts.map +1 -1
  54. package/dist/openbao/auth-role-form.d.ts +9 -0
  55. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  56. package/dist/openbao/auth-role.d.ts.map +1 -1
  57. package/dist/openbao/auth-token-form.d.ts +48 -0
  58. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  59. package/dist/openbao/bao-http.d.ts +2 -0
  60. package/dist/openbao/bao-http.d.ts.map +1 -1
  61. package/dist/openbao/bao-identity.d.ts +81 -0
  62. package/dist/openbao/bao-identity.d.ts.map +1 -0
  63. package/dist/openbao/bao-namespace.d.ts +20 -0
  64. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  65. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  66. package/dist/openbao/host-approles.d.ts +62 -0
  67. package/dist/openbao/host-approles.d.ts.map +1 -0
  68. package/dist/openbao/index.d.ts +26 -0
  69. package/dist/openbao/index.d.ts.map +1 -1
  70. package/dist/openbao/index.js +1889 -477
  71. package/dist/openbao/index.js.map +46 -17
  72. package/dist/openbao/jwt-config-form.d.ts +51 -0
  73. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  74. package/dist/openbao/jwt-config.d.ts +25 -0
  75. package/dist/openbao/jwt-config.d.ts.map +1 -0
  76. package/dist/openbao/jwt-role-form.d.ts +91 -0
  77. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  78. package/dist/openbao/jwt-role.d.ts +31 -0
  79. package/dist/openbao/jwt-role.d.ts.map +1 -0
  80. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  81. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  82. package/dist/openbao/kubernetes-role.d.ts +25 -0
  83. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  84. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  85. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  86. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  87. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  88. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  89. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  90. package/dist/openbao/mfa-totp.d.ts +33 -0
  91. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  92. package/dist/openbao/mfa-wire.d.ts +63 -0
  93. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  94. package/dist/openbao/mount-form.d.ts +7 -0
  95. package/dist/openbao/mount-form.d.ts.map +1 -1
  96. package/dist/openbao/mount-move.d.ts +49 -0
  97. package/dist/openbao/mount-move.d.ts.map +1 -0
  98. package/dist/openbao/mount-path.d.ts +18 -0
  99. package/dist/openbao/mount-path.d.ts.map +1 -0
  100. package/dist/openbao/mount-reconcile.d.ts +16 -0
  101. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  102. package/dist/openbao/mount.d.ts +9 -0
  103. package/dist/openbao/mount.d.ts.map +1 -1
  104. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  105. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  106. package/dist/openbao/pki-role-form.d.ts +38 -30
  107. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  108. package/dist/openbao/pki-role.d.ts.map +1 -1
  109. package/dist/openbao/plugin-form.d.ts +60 -0
  110. package/dist/openbao/plugin-form.d.ts.map +1 -0
  111. package/dist/openbao/plugin-reconcile.d.ts +18 -0
  112. package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
  113. package/dist/openbao/plugin-wire.d.ts +45 -0
  114. package/dist/openbao/plugin-wire.d.ts.map +1 -0
  115. package/dist/openbao/plugin.d.ts +31 -0
  116. package/dist/openbao/plugin.d.ts.map +1 -0
  117. package/dist/openbao/remount-wire.d.ts +44 -0
  118. package/dist/openbao/remount-wire.d.ts.map +1 -0
  119. package/dist/openbao/role-reconcile.d.ts +40 -0
  120. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  121. package/dist/openbao/ssh-role-form.d.ts +6 -5
  122. package/dist/openbao/ssh-role-form.d.ts.map +1 -1
  123. package/dist/openbao/ssh-role.d.ts.map +1 -1
  124. package/docs/launchd.md +167 -0
  125. package/package.json +7 -2
  126. package/src/index.ts +1 -0
  127. package/src/launchd/fake-runner.ts +176 -0
  128. package/src/launchd/host-effect.ts +28 -0
  129. package/src/launchd/host-file-form.ts +71 -0
  130. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  131. package/src/launchd/host-file-lifecycle.ts +164 -0
  132. package/src/launchd/host-file.ts +64 -0
  133. package/src/launchd/host-lookup.test.ts +92 -0
  134. package/src/launchd/host-lookup.ts +94 -0
  135. package/src/launchd/index.ts +26 -0
  136. package/src/launchd/job-form.test.ts +171 -0
  137. package/src/launchd/job-form.ts +180 -0
  138. package/src/launchd/job-identity.test.ts +147 -0
  139. package/src/launchd/job-lifecycle.test.ts +220 -0
  140. package/src/launchd/job-lifecycle.ts +194 -0
  141. package/src/launchd/job-preflight.ts +152 -0
  142. package/src/launchd/job-validate.ts +134 -0
  143. package/src/launchd/job.ts +96 -0
  144. package/src/launchd/launchctl.test.ts +181 -0
  145. package/src/launchd/launchctl.ts +188 -0
  146. package/src/launchd/local-runner.test.ts +125 -0
  147. package/src/launchd/local-runner.ts +143 -0
  148. package/src/launchd/plist.test.ts +186 -0
  149. package/src/launchd/plist.ts +156 -0
  150. package/src/launchd/providers.test.ts +222 -0
  151. package/src/launchd/providers.ts +21 -0
  152. package/src/launchd/runner.ts +95 -0
  153. package/src/launchd/secret-tripwire.test.ts +110 -0
  154. package/src/launchd/secret-tripwire.ts +81 -0
  155. package/src/openbao/README.md +181 -0
  156. package/src/openbao/REPLACE.md +87 -0
  157. package/src/openbao/approle-login-form.test.ts +64 -0
  158. package/src/openbao/approle-login-form.ts +153 -0
  159. package/src/openbao/approle-login-result.ts +96 -0
  160. package/src/openbao/approle-login.test.ts +200 -0
  161. package/src/openbao/approle-login.ts +156 -0
  162. package/src/openbao/auth-method-form.ts +7 -0
  163. package/src/openbao/auth-method-reconcile.ts +74 -0
  164. package/src/openbao/auth-method.ts +20 -41
  165. package/src/openbao/auth-role-form.test.ts +57 -0
  166. package/src/openbao/auth-role-form.ts +11 -0
  167. package/src/openbao/auth-role.ts +12 -0
  168. package/src/openbao/auth-token-form.ts +161 -0
  169. package/src/openbao/bao-http.ts +2 -0
  170. package/src/openbao/bao-identity.test.ts +131 -0
  171. package/src/openbao/bao-identity.ts +188 -0
  172. package/src/openbao/bao-namespace.ts +32 -0
  173. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  174. package/src/openbao/host-approles.test.ts +121 -0
  175. package/src/openbao/host-approles.ts +126 -0
  176. package/src/openbao/index.ts +32 -0
  177. package/src/openbao/jwt-config-form.ts +161 -0
  178. package/src/openbao/jwt-config.ts +85 -0
  179. package/src/openbao/jwt-role-form.test.ts +130 -0
  180. package/src/openbao/jwt-role-form.ts +231 -0
  181. package/src/openbao/jwt-role.ts +102 -0
  182. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  183. package/src/openbao/kubernetes-role-form.ts +149 -0
  184. package/src/openbao/kubernetes-role.ts +94 -0
  185. package/src/openbao/mfa-enforcement-form.ts +147 -0
  186. package/src/openbao/mfa-enforcement.ts +143 -0
  187. package/src/openbao/mfa-totp-form.ts +155 -0
  188. package/src/openbao/mfa-totp.ts +118 -0
  189. package/src/openbao/mfa-wire.ts +117 -0
  190. package/src/openbao/mfa.test.ts +200 -0
  191. package/src/openbao/mount-form.ts +9 -1
  192. package/src/openbao/mount-move.test.ts +164 -0
  193. package/src/openbao/mount-move.ts +113 -0
  194. package/src/openbao/mount-path.test.ts +33 -0
  195. package/src/openbao/mount-path.ts +31 -0
  196. package/src/openbao/mount-reconcile.ts +75 -0
  197. package/src/openbao/mount-wire.test.ts +13 -0
  198. package/src/openbao/mount.ts +26 -43
  199. package/src/openbao/pki-role-canonical.ts +163 -0
  200. package/src/openbao/pki-role-form.test.ts +112 -0
  201. package/src/openbao/pki-role-form.ts +76 -110
  202. package/src/openbao/pki-role.ts +12 -27
  203. package/src/openbao/plugin-form.test.ts +86 -0
  204. package/src/openbao/plugin-form.ts +164 -0
  205. package/src/openbao/plugin-reconcile.ts +92 -0
  206. package/src/openbao/plugin-wire.test.ts +146 -0
  207. package/src/openbao/plugin-wire.ts +65 -0
  208. package/src/openbao/plugin.ts +100 -0
  209. package/src/openbao/remount-wire.test.ts +87 -0
  210. package/src/openbao/remount-wire.ts +112 -0
  211. package/src/openbao/role-reconcile.test.ts +104 -0
  212. package/src/openbao/role-reconcile.ts +84 -0
  213. package/src/openbao/ssh-role-form.test.ts +105 -0
  214. package/src/openbao/ssh-role-form.ts +18 -16
  215. package/src/openbao/ssh-role.ts +3 -4
@@ -0,0 +1,181 @@
1
+ # `@homeflare/alchemy/openbao`
2
+
3
+ Vault objects as Alchemy resources: `BaoMount`, `BaoAuthMethod`, `BaoPolicy`, `BaoAuthRole`,
4
+ `BaoJwtRole`, `BaoKubernetesRole`, `BaoJwtAuthConfig`, `BaoMfaTotpMethod`,
5
+ `BaoMfaLoginEnforcement`, `BaoPkiRole`, `BaoSshRole`, `BaoCloudflareRole`, `BaoProxmoxRole` and
6
+ `BaoPlugin`. It also has three plain helpers: `assertBaoIdentity`, `hostAppRoles` and
7
+ `appRoleLogin`.
8
+
9
+ Every call resolves `BAO_ADDR`, `BAO_AGENT_ADDR`, `BAO_NAMESPACE` and `BAO_TOKEN` (or their
10
+ `VAULT_*` twins) the same way the `bao` CLI does. A stack provides each `Bao*Provider()` it uses,
11
+ plus `FetchHttpClient.layer`.
12
+
13
+ - ⛔ **Metadata only.** Alchemy stores props and attributes unencrypted, so you cannot declare a
14
+ secret, a CA key, a plugin `env` or an OIDC client secret.
15
+ - Every family defaults to `retain` on destroy. For what a rename does, read
16
+ [REPLACE.md](./REPLACE.md) before you change any resource's path or name.
17
+
18
+ ## assertBaoIdentity: call it first
19
+
20
+ ```ts
21
+ import { assertBaoIdentityEffect } from '@homeflare/alchemy/openbao';
22
+
23
+ export const guarded = Effect.gen(function* () {
24
+ yield* assertBaoIdentityEffect({ clusterName: 'vault-example', namespace: 'team-a' });
25
+ // …every Bao.* resource after this
26
+ });
27
+ ```
28
+
29
+ - It reads the unauthenticated `sys/health` with no token and no namespace header, because
30
+ health is root-only. It refuses a sealed vault, the wrong `cluster_name`, a `BAO_NAMESPACE`
31
+ that is not the expected one, and a namespace the cluster does not have.
32
+ - ⚠️ It fails with a `BaoIdentityError` whose message starts with `REFUSING:`. The `reason` is
33
+ `input`, `mismatch`, `unconfirmed` or `unreachable`. `assertBaoIdentity` is the Promise form.
34
+ - ⚠️ It catches a mis-set address, not an impostor. Any server can repeat a `cluster_name`;
35
+ TLS is what proves which server answered.
36
+
37
+ ## BaoMount / BaoAuthMethod: moving a path
38
+
39
+ ```ts
40
+ export const kv = Effect.gen(function* () {
41
+ yield* BaoMount('kv', { path: 'secrets', remountFrom: 'kv', type: 'kv', version: 2 });
42
+ });
43
+ ```
44
+
45
+ - ⛔ If `path` changes, the plan now fails unless `remountFrom` names the old path. Before this, a
46
+ path change enabled an empty mount at the new path.
47
+ - With `remountFrom`, the provider moves the mount with `sys/remount` and polls until the move is
48
+ done, so the data stays. ⛔ The move revokes every lease under the old path, and it does not
49
+ rewrite policies that name that path. The deploying token needs `sudo` on `sys/remount`.
50
+
51
+ ## hostAppRoles: one AppRole per host
52
+
53
+ ```ts
54
+ export const hostRoles = Effect.gen(function* () {
55
+ const roles = hostAppRoles({
56
+ classes: {
57
+ 'pve-node': {
58
+ policies: ['pve-node'],
59
+ secretIdTtl: '2160h',
60
+ tokenMaxTtl: '1h',
61
+ tokenTtl: '15m',
62
+ },
63
+ },
64
+ hosts: [{ class: 'pve-node', name: 'node-a' }],
65
+ });
66
+ for (const props of roles) yield* BaoAuthRole(props.name, props); // `pve-node--node-a`
67
+ });
68
+ ```
69
+
70
+ - A role is named `<class>--<host>`. Class and host names may contain only lowercase letters,
71
+ digits and single hyphens.
72
+ - It refuses `secretIdTtl` 0 (a secret_id that never expires), empty policies, `root`, unknown
73
+ classes and duplicate hosts.
74
+ - ⚠️ Renaming a host or class makes a new role. Under `retain` the old one stays live and keeps
75
+ admitting its secret_ids (REPLACE.md).
76
+
77
+ ## BaoJwtRole / BaoKubernetesRole / BaoJwtAuthConfig
78
+
79
+ ```ts
80
+ export const machines = Effect.gen(function* () {
81
+ const issuer = 'https://issuer.example.com';
82
+ yield* BaoJwtAuthConfig('gh', {
83
+ boundIssuer: issuer,
84
+ mount: 'jwt-github',
85
+ oidcDiscoveryUrl: issuer,
86
+ });
87
+ yield* BaoJwtRole('ci', {
88
+ boundAudiences: ['https://example.com/org'],
89
+ boundClaims: { ref: 'refs/heads/main' },
90
+ mount: 'jwt-github',
91
+ name: 'ci-app',
92
+ roleType: 'jwt',
93
+ tokenPolicies: ['ci-app'],
94
+ tokenTtl: '10m',
95
+ userClaim: 'repository',
96
+ });
97
+ yield* BaoKubernetesRole('pod', {
98
+ aliasNameSource: 'serviceaccount_name',
99
+ boundServiceAccountNames: ['litellm'],
100
+ boundServiceAccountNamespaces: ['ai'],
101
+ name: 'ai-litellm',
102
+ tokenPolicies: ['ai-litellm'],
103
+ });
104
+ });
105
+ ```
106
+
107
+ - ⛔ `roleType` is required. OpenBao's default is `oidc`, even when it rewrites a `jwt` role.
108
+ - ⛔ `aliasNameSource` is required too. Changing it on a live role moves every pod to a new entity.
109
+ - Every managed field is sent on every write, so a value someone set by hand cannot sit
110
+ unnoticed behind a green plan. `verbose_oidc_logging` and `oidc_disable_confirmation` are
111
+ always sent as `false`.
112
+ - ⛔ `BaoJwtAuthConfig` has no OIDC client secret, and it refuses a mount whose live config has
113
+ an `oidc_client_id`: its full-replace write would erase that secret, which the read never
114
+ returns. It has no delete endpoint, so its delete writes nothing.
115
+
116
+ ## Login MFA: BaoMfaTotpMethod + BaoMfaLoginEnforcement
117
+
118
+ ```ts
119
+ export const adminMfa = Effect.gen(function* () {
120
+ const totp = yield* BaoMfaTotpMethod('admin-totp', {
121
+ issuer: 'vault-example',
122
+ name: 'admin-totp',
123
+ });
124
+ yield* BaoMfaLoginEnforcement('admin-mfa', {
125
+ authMethodPaths: ['oidc-admin'],
126
+ mfaMethodIds: [totp.methodId],
127
+ name: 'admin-mfa',
128
+ });
129
+ });
130
+ ```
131
+
132
+ - ⛔ **Enrol before you enforce.** An entity with no TOTP secret fails every login the
133
+ enforcement matches. Enrol admins with `admin-generate`, never through Alchemy.
134
+ - ⚠️ An enforcement matches mounts, identity groups or entities. It never matches a role. Target
135
+ mounts by path; `authMethodTypes: ['jwt']` also catches machine JWT mounts.
136
+ - ⛔ Renaming a TOTP method fails the plan, because it would strand every enrolled secret.
137
+ Deleting an enforcement is refused: in OpenBao 2.6.2 a delete comes back after a restart
138
+ (openbao/openbao#4030).
139
+
140
+ ## appRoleLogin / revokeSelf (for scripts)
141
+
142
+ ```ts
143
+ const login = await appRoleLogin({ roleId, secretId }); // mount defaults to `approle`
144
+ try {
145
+ use(login.clientToken, login.accessor, login.policies, login.leaseDurationSeconds);
146
+ } finally {
147
+ await revokeSelf(login.clientToken);
148
+ }
149
+ ```
150
+
151
+ - ⛔ The login never sends `BAO_TOKEN`, and a `BaoLoginError` never contains the credential.
152
+ OpenBao can echo a secret_id back in an error, so every error string is redacted.
153
+ - ⚠️ `clientToken` is a getter over a private field. `console.log(login)` prints `[Getter]` at
154
+ most under Bun and nothing under Node. JSON, a spread and `structuredClone` carry no token.
155
+ - ⚠️ A value with leading or trailing whitespace is refused, not trimmed. Note that
156
+ `Bun.file().text()` keeps a file's trailing newline.
157
+ - The error's `reason` is `input`, `refused`, `unreachable` or `response`. Pass `env` to use
158
+ something other than `process.env`. `appRoleLoginEffect` and `revokeSelfEffect` are the same
159
+ calls as Effects.
160
+
161
+ ## BaoPlugin
162
+
163
+ ```ts
164
+ export const plugins = Effect.gen(function* () {
165
+ yield* BaoPlugin('plugin-example', {
166
+ name: 'openbao-plugin-secrets-example',
167
+ type: 'secret', // 'secret' | 'auth' | 'database'
168
+ command: 'openbao-plugin-secrets-example', // a bare file name in plugin_directory
169
+ sha256: '<hex sha256 of that file>',
170
+ version: 'v0.1.2', // canonical semver
171
+ });
172
+ });
173
+ ```
174
+
175
+ - ⛔ It registers a binary that is already in `plugin_directory`. It does not copy the binary.
176
+ - ⚠️ **If the binary reports its own version, declare exactly that version.** OpenBao refuses any
177
+ other version ("plugin version mismatch"). It files an unversioned registration under the
178
+ reported version, and reconcile then refuses because the read-back finds nothing.
179
+ - ⚠️ A new `sha256` does not restart running mounts. Reload them with
180
+ `sys/plugins/reload/backend`.
181
+ - The deploying token needs `sudo` on `sys/plugins/catalog/*`.
@@ -0,0 +1,87 @@
1
+ # Replace semantics: the `Bao.*` providers
2
+
3
+ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source. Every kit
4
+ provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
5
+
6
+ ## What Alchemy does with each answer
7
+
8
+ - **`update`**: `reconcile` runs against the same resource. Only safe when the object's identity
9
+ (its path) is unchanged.
10
+ - **`replace`**: create-first by default. The new generation is created, dependents are updated in
11
+ the same graph, then the old generation is deleted (`Apply.ts`, delete-first comment near :1180).
12
+ - **`deleteFirst: true`**: tears the old one down first. It is only for identities that cannot
13
+ coexist, such as a unique physical name. No `Bao.*` provider uses it. A rename is a new API
14
+ path, which never collides with the old one. ⚠️ A mount or auth method whose `type` changes
15
+ keeps its path, so the two generations DO collide. It deliberately stays create-first, and the
16
+ create fails (see the table).
17
+ - **`retain`**, the default for every `Bao.*` family, **keeps the old generation of a replace**
18
+ (`Apply.ts:2164-2173`, "Retaining replaced resource"). ⚠️ So under the default, a replaced role
19
+ **stays live** and still admits logins. Opt into `RemovalPolicy.destroy()` where that matters
20
+ (per-host AppRoles), or remove it by hand.
21
+
22
+ ## The bug class this audit found
23
+
24
+ ⛔ **A changed identity planned `update`.** `diff` read the NEW path, found nothing, answered
25
+ `update`, and `reconcile` created the object at the new path. The old object stayed live with no
26
+ state record, so no later plan would mention it. For a mount this meant an **empty** new mount
27
+ beside the retained one that held every secret. For an AppRole it meant the old role's secret_ids
28
+ kept working for their whole TTL.
29
+
30
+ ## Touched in this batch
31
+
32
+ | Resource | Identity change | Answer now | Other changes |
33
+ | ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
34
+ | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
35
+ | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
36
+ | `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
37
+ | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
38
+ | `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
39
+ | `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
40
+ | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
41
+ | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
42
+ | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
43
+
44
+ `hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
45
+ renames the role. With the logical id taken from the role name, as the README does, the old id
46
+ leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
47
+ `Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
48
+
49
+ ## Why the moves fail instead of replacing
50
+
51
+ - **Mount and auth path.** A `replace` would create an empty mount. Deleting the old one would
52
+ destroy every secret, and with the default `retain` the data would be stranded. `sys/remount`
53
+ is the only move that keeps the data: the mount keeps its UUID, and storage lives under the
54
+ UUID (`vault/mount.go:630-735`). ⛔ It **revokes every lease** under the old path
55
+ (`RevokePrefix`, :684), and it rewrites no policy that names the old path.
56
+ - **TOTP method name.** Every enrolled secret is keyed by the method id
57
+ (`entity.MFASecrets[id]`). The safe rename adds a second method, lists both on the enforcement
58
+ (any one passing is enough), enrols everyone, then drops the old method.
59
+
60
+ ## Why a type change fails instead of replacing
61
+
62
+ The plan says `replace`, but the new generation reads the same path, finds the old type there,
63
+ and its reconcile dies with "type is immutable" before any write. So the deploy fails, and
64
+ nothing is disabled under either removal policy. Change a type by hand. ⛔ `deleteFirst` would
65
+ make it succeed, but under `destroy` it disables the mount first, and a mistyped `type` would
66
+ take every secret with it. (Corrected 2026-09-21: the first audit listed this as a clean
67
+ `replace`.)
68
+
69
+ ## The plan's three exceptions, mapped
70
+
71
+ 1. **`retain` resources orphan the old generation on a replace.** That applies to all of the
72
+ above. A mount is never replaced for a rename. It is moved (`remountFrom`), which works like
73
+ adopting it at the new path.
74
+ 2. **Referents outside the graph.** These do not follow a replace: host credential files (a new
75
+ AppRole means a new role_id), login URLs and CLI `-path` flags naming an auth mount or role,
76
+ policies naming a mount path, and third-party OIDC clients holding a redirect URI. Rotate or
77
+ move them in the same PR.
78
+ 3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
79
+ it fails on purpose instead (above).
80
+
81
+ ## Audited, not touched in this batch (open)
82
+
83
+ - ⚠️ `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` still compare only the live object
84
+ at the declared `name` (and `mount`). A rename plans `update` and orphans the old object, which
85
+ is the same bug class as above. `Bao.ProxmoxRole` deliberately never answers `replace` for a
86
+ `mint_user` change (its own comment), and that choice is sound. The rename case is separate.
87
+ - `Bao.SshRole` and `Bao.Plugin` already answer `replace` on an identity change.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The pure decisions behind appRoleLogin: which failure a status is, which mounts may be spliced
3
+ * into a URL, and what redaction removes. No server — approle-login.test.ts drives the fake.
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import { failureOf, loginPath, mountProblem, redact } from './approle-login-form.ts';
8
+
9
+ describe('failureOf', () => {
10
+ it('reads 0 as unreachable, 2xx as a bad response, anything else as refused', () => {
11
+ assert.equal(failureOf(0), 'unreachable');
12
+ assert.equal(failureOf(200), 'response');
13
+ assert.equal(failureOf(204), 'response');
14
+ assert.equal(failureOf(400), 'refused');
15
+ assert.equal(failureOf(503), 'refused');
16
+ });
17
+ });
18
+
19
+ describe('mountProblem', () => {
20
+ it('accepts plain and nested mounts, trimming outer slashes', () => {
21
+ for (const mount of [undefined, 'approle', '/approle-hosts/', 'team/approle']) {
22
+ assert.equal(mountProblem(mount), undefined, String(mount));
23
+ }
24
+ assert.equal(loginPath('/team/approle/'), 'auth/team/approle/login');
25
+ });
26
+
27
+ it('refuses anything the URL parser would move or cut short', () => {
28
+ for (const mount of ['', '/', '..', 'a/../b', './a', 'a//b', 'a?b', 'a#b', 'a%2e', 'a b']) {
29
+ assert.notEqual(mountProblem(mount), undefined, mount);
30
+ }
31
+ });
32
+
33
+ it('trims long runs of slashes in linear time (no polynomial regex on caller input)', () => {
34
+ const run = '/'.repeat(200_000);
35
+ const started = performance.now();
36
+ assert.equal(loginPath(`${run}approle${run}`), 'auth/approle/login');
37
+ assert.notEqual(mountProblem(`${run}a${run}x`), undefined);
38
+ assert.ok(performance.now() - started < 1_000, 'slash trimming took over a second');
39
+ });
40
+ });
41
+
42
+ describe('redact', () => {
43
+ it('removes the raw value wherever it appears', () => {
44
+ assert.deepEqual(redact(['x secret-1 y secret-1'], ['secret-1']), [
45
+ 'x [redacted] y [redacted]',
46
+ ]);
47
+ });
48
+
49
+ it('removes the %q form OpenBao echoes (path_login.go:291), escapes and all', () => {
50
+ const secret = 'a"b\\c';
51
+ const echoed = `invalid secret_id "a\\"b\\\\c"`;
52
+ assert.deepEqual(redact([echoed], [secret]), ['invalid secret_id "[redacted]"']);
53
+ });
54
+
55
+ it('redacts the longer value first, so a shorter one cannot split it', () => {
56
+ const [text] = redact(['role abc and secret abcdef'], ['abc', 'abcdef']);
57
+ assert.equal(text, 'role [redacted] and secret [redacted]');
58
+ assert.ok(!text?.includes('def'));
59
+ });
60
+
61
+ it('ignores an empty value rather than redacting every gap', () => {
62
+ assert.deepEqual(redact(['kept'], ['']), ['kept']);
63
+ });
64
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The pure half of an AppRole login's INPUT and FAILURE: what counts as a usable input, and how a
3
+ * failure is described without the credential in it. Split from approle-login.ts so each decision
4
+ * is testable without a server; what a success hands back is approle-login-result.ts.
5
+ *
6
+ * ★ READ FROM openbao v2.6.2, NOT RECALLED:
7
+ * · docs/api/auth/approle.mdx "Login with AppRole" — `role_id` and `secret_id` in, an `auth` block
8
+ * out.
9
+ * · api/auth/approle/approle.go:145-146 — the official Go helper writes `auth/<mount>/login`, and
10
+ * the mount is `approle` unless the caller says otherwise. (api/*.go cited here diffed identical
11
+ * between v2.6.0 and v2.6.2.)
12
+ */
13
+ import { trimSlashes } from './mount-path.ts';
14
+
15
+ /** Why a login or a revoke failed. */
16
+ export type BaoLoginFailure =
17
+ /** Refused before any request was sent: an empty or padded value, or a mount that is not a path. */
18
+ | 'input'
19
+ /** OpenBao (or something at BAO_ADDR) answered with a non-2xx status. */
20
+ | 'refused'
21
+ /** No answer at all — transport, timeout, or an address nothing listens on. */
22
+ | 'unreachable'
23
+ /** A 2xx that is not a usable answer: not JSON, no auth block, or MFA still required. */
24
+ | 'response';
25
+
26
+ /**
27
+ * The failure a transport-level BaoError stands for.
28
+ *
29
+ * ⚠️ A 2xx CAN FAIL TOO, AND IT IS NOT A REFUSAL. bao-status.ts settle fails a success whose body is
30
+ * not a JSON object — the shape of a BAO_ADDR that points at some other web server. The first
31
+ * version mapped every non-zero status to `refused`, so that read as `refused 200`: a server that
32
+ * said yes, reported as one that said no.
33
+ */
34
+ export const failureOf = (status: number): BaoLoginFailure =>
35
+ status === 0 ? 'unreachable' : status >= 200 && status < 300 ? 'response' : 'refused';
36
+
37
+ /**
38
+ * A failed login or revoke.
39
+ *
40
+ * ⛔ IT NEVER CARRIES A CREDENTIAL. It is built from the operation name, a status and a list of
41
+ * strings that have been through `redact` — never from the request, a header, or a `cause`, for
42
+ * the reason bao-status.ts gives for BaoError.
43
+ */
44
+ export class BaoLoginError extends Error {
45
+ constructor(
46
+ readonly reason: BaoLoginFailure,
47
+ /** The HTTP status, or 0 when none applies (input, unreachable, an unusable auth block). */
48
+ readonly status: number,
49
+ readonly errors: readonly string[],
50
+ operation: string,
51
+ ) {
52
+ const code = status === 0 ? reason : `${reason} ${String(status)}`;
53
+ super(`${operation}: ${code}: ${errors.join('; ') || '(no errors given)'}`);
54
+ this.name = 'BaoLoginError';
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Why a value cannot be sent, or undefined when it can.
60
+ *
61
+ * ⚠️ PADDING IS REFUSED, NOT TRIMMED. `await Bun.file(path).text()` keeps a secret_id file's trailing
62
+ * newline, and OpenBao answers that with "invalid role or secret ID" — the same words it uses for
63
+ * a revoked or expired credential, so the real cause is invisible from the error. Trimming quietly
64
+ * would send a different value from the one the caller holds; refusing names the problem.
65
+ * ⚠️ `unknown`, NOT `string`: a plain script passes `process.env.X`, which is `undefined` when unset.
66
+ */
67
+ export const refusal = (label: string, value: unknown): string | undefined => {
68
+ if (typeof value !== 'string' || value === '') return `${label} is empty`;
69
+ if (value.trim() === '') return `${label} is only whitespace`;
70
+ if (value.trim() !== value) {
71
+ return `${label} has leading or trailing whitespace (a file read keeps its newline; trim it)`;
72
+ }
73
+ return undefined;
74
+ };
75
+
76
+ /** The auth mount without slashes; `approle` when the caller did not say. */
77
+ export const loginMount = (mount: string | undefined): string => trimSlashes(mount ?? 'approle');
78
+
79
+ /**
80
+ * Why `mount` cannot name an auth mount, or undefined when it can.
81
+ *
82
+ * ⛔ THE MOUNT IS SPLICED INTO A URL, AND THE URL PARSER RESOLVES `..` AND ENDS THE PATH AT `?` OR
83
+ * `#`. `new URL('…/v1/auth/../sys/x/login')` is `…/v1/sys/x/login` — the role_id and secret_id
84
+ * would be sent to a path the caller never named. Refused rather than escaped, like padding.
85
+ */
86
+ export const mountProblem = (mount: string | undefined): string | undefined => {
87
+ const path = loginMount(mount);
88
+ if (path === '') return 'mount is empty';
89
+ const segments = path.split('/');
90
+ if (
91
+ /[\s?#%\\]/.test(path) ||
92
+ segments.some((part) => part === '' || part === '.' || part === '..')
93
+ ) {
94
+ return `mount ${JSON.stringify(path)} is not a plain mount path`;
95
+ }
96
+ return undefined;
97
+ };
98
+
99
+ /** `auth/<mount>/login` — what api/auth/approle/approle.go:145 writes. */
100
+ export const loginPath = (mount: string | undefined): string => `auth/${loginMount(mount)}/login`;
101
+
102
+ /** strconv.Quote's single-letter escapes, by code point. */
103
+ const GO_ESCAPES: Readonly<Record<number, string>> = {
104
+ 7: 'a',
105
+ 8: 'b',
106
+ 9: 't',
107
+ 10: 'n',
108
+ 11: 'v',
109
+ 12: 'f',
110
+ 13: 'r',
111
+ };
112
+
113
+ /**
114
+ * `value` as it appears between the quotes of Go's `%q` (strconv.Quote) — ASCII escapes only.
115
+ * ⚠️ Non-ASCII passes through as-is, which is what strconv.Quote does for PRINTABLE runes; a
116
+ * non-printable non-ASCII rune in a secret would escape differently and is not covered.
117
+ */
118
+ const goQuoted = (value: string): string =>
119
+ [...value]
120
+ .map((char) => {
121
+ const code = char.codePointAt(0) ?? 0;
122
+ const named = GO_ESCAPES[code];
123
+ if (char === '"' || char === '\\') return `\\${char}`;
124
+ if (named !== undefined) return `\\${named}`;
125
+ if (code < 0x20 || code === 0x7f) return `\\x${code.toString(16).padStart(2, '0')}`;
126
+ return char;
127
+ })
128
+ .join('');
129
+
130
+ /**
131
+ * Every string in `errors` with each credential replaced by `[redacted]`.
132
+ *
133
+ * ⛔ OPENBAO CAN ECHO THE SECRET_ID BACK, SO THIS IS NOT DEFENCE IN DEPTH — IT IS THE DEFENCE.
134
+ * openbao v2.6.2 builtin/credential/approle/path_login.go:291 answers a limited-use secret_id
135
+ * whose storage entry vanished between two reads with `invalid secret_id "<the secret_id>"`.
136
+ * BaoError keeps OpenBao's `errors` verbatim (that is its job), so every string that leaves the
137
+ * login or revoke passes through here first.
138
+ * ⚠️ THAT ECHO IS `%q`, SO THE RAW VALUE IS NOT ENOUGH. A custom secret_id holding `"` or `\` comes
139
+ * back escaped (`\"`, `\\`) and would slip past a raw match; the Go-quoted form is redacted too.
140
+ * ⚠️ LONGEST FIRST. Were one value a substring of another, redacting the shorter first would break
141
+ * the longer one's match and leave the rest of it in the message.
142
+ */
143
+ export const redact = (
144
+ errors: readonly string[],
145
+ secrets: readonly string[],
146
+ ): readonly string[] => {
147
+ const forms = [...new Set(secrets.flatMap((secret) => [secret, goQuoted(secret)]))]
148
+ .filter((form) => form !== '')
149
+ .sort((a, b) => b.length - a.length);
150
+ return errors.map((entry) =>
151
+ forms.reduce((text, form) => text.split(form).join('[redacted]'), entry),
152
+ );
153
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * What a successful AppRole login hands back, and how it is read out of a 2xx body. Split from
3
+ * approle-login-form.ts, which decides what goes IN; this decides what comes OUT and how the token
4
+ * stays out of every log line that prints the result.
5
+ *
6
+ * ★ READ FROM openbao v2.6.2 api/secret.go:304-318 SecretAuth — `client_token`, `accessor`,
7
+ * `policies`, `lease_duration` and `mfa_requirement`. (api/*.go cited here diffed identical
8
+ * between v2.6.0 and v2.6.2.)
9
+ */
10
+
11
+ /** What a successful login hands back. */
12
+ export interface AppRoleLogin {
13
+ /**
14
+ * The token. ⛔ A GETTER OVER A PRIVATE FIELD, NEVER AN OWN PROPERTY — see LoginResult below.
15
+ * `console.log(login)` prints `clientToken: [Getter]` at most, and `JSON.stringify(login)`,
16
+ * `{ ...login }` and `structuredClone(login)` carry no token at all. Read it by name.
17
+ */
18
+ readonly clientToken: string;
19
+ /** The token's accessor — safe to log, and what revoke-accessor and audit lines name. */
20
+ readonly accessor: string;
21
+ /** Every policy on the token: the role's token_policies plus identity policies. */
22
+ readonly policies: readonly string[];
23
+ /** The token TTL in seconds, from the role's `token_ttl`. */
24
+ readonly leaseDurationSeconds: number;
25
+ }
26
+
27
+ /**
28
+ * ⛔ A PRIVATE FIELD BEHIND A PROTOTYPE GETTER, BECAUSE A NON-ENUMERABLE PROPERTY DID NOT HIDE THE
29
+ * TOKEN UNDER BUN. MEASURED 2026-09-21 on bun 1.4.0: `console.log` of an object whose token was a
30
+ * non-enumerable OWN property printed `clientToken: "<the token>"` in full — node's util.inspect
31
+ * hid it, bun's formatter did not — and this helper exists for Bun scripts. The first version
32
+ * shipped exactly that shape, with a test that checked JSON and Object.keys but never a print.
33
+ * This shape prints `clientToken: [Getter]` under bun's console.log and Bun.inspect, and nothing
34
+ * at all under node's default inspect.
35
+ * ⚠️ `util.inspect(login, { getters: true })` DOES evaluate the getter. That is an explicit request
36
+ * for the value, not an accident of logging the result.
37
+ */
38
+ class LoginResult implements AppRoleLogin {
39
+ readonly #token: string;
40
+ readonly accessor: string;
41
+ readonly policies: readonly string[];
42
+ readonly leaseDurationSeconds: number;
43
+
44
+ constructor(token: string, accessor: string, policies: readonly string[], lease: number) {
45
+ this.#token = token;
46
+ this.accessor = accessor;
47
+ this.policies = Object.freeze([...policies]);
48
+ this.leaseDurationSeconds = lease;
49
+ Object.freeze(this);
50
+ }
51
+
52
+ get clientToken(): string {
53
+ return this.#token;
54
+ }
55
+ }
56
+
57
+ const strings = (value: unknown): readonly string[] =>
58
+ Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
59
+
60
+ /**
61
+ * The login in a 2xx body, or the reason there is none.
62
+ *
63
+ * ⚠️ AN EMPTY `client_token` WITH A `mfa_requirement` IS AN UNFINISHED LOGIN, NOT A BROKEN ONE. The
64
+ * auth block carries that field for a login that still owes an MFA step (api/secret.go:317). A
65
+ * script cannot complete that step, so it is named rather than reported as a missing field.
66
+ * REASONED FROM THE CLIENT TYPE — no MFA-enforced login was run.
67
+ * ⛔ THE BODY IS NEVER QUOTED. It holds the token on success and is not OpenBao's on a wrong address.
68
+ */
69
+ export const loginOf = (
70
+ body: Record<string, unknown> | undefined,
71
+ ): { readonly login: AppRoleLogin } | { readonly problem: string } => {
72
+ const auth = body?.['auth'];
73
+ if (typeof auth !== 'object' || auth === null || Array.isArray(auth)) {
74
+ return { problem: 'the answer carried no auth block' };
75
+ }
76
+ const fields = auth as Record<string, unknown>;
77
+ const token = fields['client_token'];
78
+ if (typeof token !== 'string' || token === '') {
79
+ const mfa = fields['mfa_requirement'];
80
+ return {
81
+ problem:
82
+ mfa !== undefined && mfa !== null
83
+ ? 'the login requires MFA, which this helper does not complete'
84
+ : 'the auth block carried no client_token',
85
+ };
86
+ }
87
+ const lease = fields['lease_duration'];
88
+ return {
89
+ login: new LoginResult(
90
+ token,
91
+ typeof fields['accessor'] === 'string' ? fields['accessor'] : '',
92
+ strings(fields['policies']),
93
+ typeof lease === 'number' && Number.isFinite(lease) ? lease : 0,
94
+ ),
95
+ };
96
+ };