@homeflare/alchemy 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +17 -48
  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/auth-method-form.d.ts +7 -0
  43. package/dist/openbao/auth-method-form.d.ts.map +1 -1
  44. package/dist/openbao/auth-method-reconcile.d.ts +15 -0
  45. package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
  46. package/dist/openbao/auth-method.d.ts +6 -0
  47. package/dist/openbao/auth-method.d.ts.map +1 -1
  48. package/dist/openbao/auth-role-form.d.ts +9 -0
  49. package/dist/openbao/auth-role-form.d.ts.map +1 -1
  50. package/dist/openbao/auth-role.d.ts.map +1 -1
  51. package/dist/openbao/auth-token-form.d.ts +48 -0
  52. package/dist/openbao/auth-token-form.d.ts.map +1 -0
  53. package/dist/openbao/bao-identity.d.ts +81 -0
  54. package/dist/openbao/bao-identity.d.ts.map +1 -0
  55. package/dist/openbao/bao-namespace.d.ts +20 -0
  56. package/dist/openbao/bao-namespace.d.ts.map +1 -0
  57. package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
  58. package/dist/openbao/host-approles.d.ts +62 -0
  59. package/dist/openbao/host-approles.d.ts.map +1 -0
  60. package/dist/openbao/index.d.ts +19 -0
  61. package/dist/openbao/index.d.ts.map +1 -1
  62. package/dist/openbao/index.js +1433 -305
  63. package/dist/openbao/index.js.map +34 -13
  64. package/dist/openbao/jwt-config-form.d.ts +51 -0
  65. package/dist/openbao/jwt-config-form.d.ts.map +1 -0
  66. package/dist/openbao/jwt-config.d.ts +25 -0
  67. package/dist/openbao/jwt-config.d.ts.map +1 -0
  68. package/dist/openbao/jwt-role-form.d.ts +91 -0
  69. package/dist/openbao/jwt-role-form.d.ts.map +1 -0
  70. package/dist/openbao/jwt-role.d.ts +31 -0
  71. package/dist/openbao/jwt-role.d.ts.map +1 -0
  72. package/dist/openbao/kubernetes-role-form.d.ts +68 -0
  73. package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
  74. package/dist/openbao/kubernetes-role.d.ts +25 -0
  75. package/dist/openbao/kubernetes-role.d.ts.map +1 -0
  76. package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
  77. package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
  78. package/dist/openbao/mfa-enforcement.d.ts +41 -0
  79. package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
  80. package/dist/openbao/mfa-totp-form.d.ts +52 -0
  81. package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
  82. package/dist/openbao/mfa-totp.d.ts +33 -0
  83. package/dist/openbao/mfa-totp.d.ts.map +1 -0
  84. package/dist/openbao/mfa-wire.d.ts +63 -0
  85. package/dist/openbao/mfa-wire.d.ts.map +1 -0
  86. package/dist/openbao/mount-form.d.ts +7 -0
  87. package/dist/openbao/mount-form.d.ts.map +1 -1
  88. package/dist/openbao/mount-move.d.ts +49 -0
  89. package/dist/openbao/mount-move.d.ts.map +1 -0
  90. package/dist/openbao/mount-path.d.ts +8 -4
  91. package/dist/openbao/mount-path.d.ts.map +1 -1
  92. package/dist/openbao/mount-reconcile.d.ts +16 -0
  93. package/dist/openbao/mount-reconcile.d.ts.map +1 -0
  94. package/dist/openbao/mount.d.ts +9 -0
  95. package/dist/openbao/mount.d.ts.map +1 -1
  96. package/dist/openbao/pki-role-canonical.d.ts +46 -0
  97. package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
  98. package/dist/openbao/pki-role-form.d.ts +38 -30
  99. package/dist/openbao/pki-role-form.d.ts.map +1 -1
  100. package/dist/openbao/pki-role.d.ts.map +1 -1
  101. package/dist/openbao/remount-wire.d.ts +44 -0
  102. package/dist/openbao/remount-wire.d.ts.map +1 -0
  103. package/dist/openbao/role-reconcile.d.ts +40 -0
  104. package/dist/openbao/role-reconcile.d.ts.map +1 -0
  105. package/docs/launchd.md +167 -0
  106. package/package.json +7 -2
  107. package/src/index.ts +1 -0
  108. package/src/launchd/fake-runner.ts +176 -0
  109. package/src/launchd/host-effect.ts +28 -0
  110. package/src/launchd/host-file-form.ts +71 -0
  111. package/src/launchd/host-file-lifecycle.test.ts +198 -0
  112. package/src/launchd/host-file-lifecycle.ts +164 -0
  113. package/src/launchd/host-file.ts +64 -0
  114. package/src/launchd/host-lookup.test.ts +92 -0
  115. package/src/launchd/host-lookup.ts +94 -0
  116. package/src/launchd/index.ts +26 -0
  117. package/src/launchd/job-form.test.ts +171 -0
  118. package/src/launchd/job-form.ts +180 -0
  119. package/src/launchd/job-identity.test.ts +147 -0
  120. package/src/launchd/job-lifecycle.test.ts +220 -0
  121. package/src/launchd/job-lifecycle.ts +194 -0
  122. package/src/launchd/job-preflight.ts +152 -0
  123. package/src/launchd/job-validate.ts +134 -0
  124. package/src/launchd/job.ts +96 -0
  125. package/src/launchd/launchctl.test.ts +181 -0
  126. package/src/launchd/launchctl.ts +188 -0
  127. package/src/launchd/local-runner.test.ts +125 -0
  128. package/src/launchd/local-runner.ts +143 -0
  129. package/src/launchd/plist.test.ts +186 -0
  130. package/src/launchd/plist.ts +156 -0
  131. package/src/launchd/providers.test.ts +222 -0
  132. package/src/launchd/providers.ts +21 -0
  133. package/src/launchd/runner.ts +95 -0
  134. package/src/launchd/secret-tripwire.test.ts +110 -0
  135. package/src/launchd/secret-tripwire.ts +81 -0
  136. package/src/openbao/README.md +181 -0
  137. package/src/openbao/REPLACE.md +87 -0
  138. package/src/openbao/auth-method-form.ts +7 -0
  139. package/src/openbao/auth-method-reconcile.ts +74 -0
  140. package/src/openbao/auth-method.ts +20 -41
  141. package/src/openbao/auth-role-form.test.ts +16 -1
  142. package/src/openbao/auth-role-form.ts +11 -0
  143. package/src/openbao/auth-role.ts +12 -0
  144. package/src/openbao/auth-token-form.ts +161 -0
  145. package/src/openbao/bao-identity.test.ts +131 -0
  146. package/src/openbao/bao-identity.ts +188 -0
  147. package/src/openbao/bao-namespace.ts +32 -0
  148. package/src/openbao/cloudflare-roles-expand.ts +2 -5
  149. package/src/openbao/host-approles.test.ts +121 -0
  150. package/src/openbao/host-approles.ts +126 -0
  151. package/src/openbao/index.ts +19 -0
  152. package/src/openbao/jwt-config-form.ts +161 -0
  153. package/src/openbao/jwt-config.ts +85 -0
  154. package/src/openbao/jwt-role-form.test.ts +130 -0
  155. package/src/openbao/jwt-role-form.ts +231 -0
  156. package/src/openbao/jwt-role.ts +102 -0
  157. package/src/openbao/kubernetes-role-form.test.ts +82 -0
  158. package/src/openbao/kubernetes-role-form.ts +149 -0
  159. package/src/openbao/kubernetes-role.ts +94 -0
  160. package/src/openbao/mfa-enforcement-form.ts +147 -0
  161. package/src/openbao/mfa-enforcement.ts +143 -0
  162. package/src/openbao/mfa-totp-form.ts +155 -0
  163. package/src/openbao/mfa-totp.ts +118 -0
  164. package/src/openbao/mfa-wire.ts +117 -0
  165. package/src/openbao/mfa.test.ts +200 -0
  166. package/src/openbao/mount-form.ts +9 -1
  167. package/src/openbao/mount-move.test.ts +164 -0
  168. package/src/openbao/mount-move.ts +113 -0
  169. package/src/openbao/mount-path.test.ts +11 -1
  170. package/src/openbao/mount-path.ts +17 -11
  171. package/src/openbao/mount-reconcile.ts +75 -0
  172. package/src/openbao/mount.ts +26 -43
  173. package/src/openbao/pki-role-canonical.ts +163 -0
  174. package/src/openbao/pki-role-form.test.ts +112 -0
  175. package/src/openbao/pki-role-form.ts +76 -110
  176. package/src/openbao/pki-role.ts +12 -27
  177. package/src/openbao/remount-wire.test.ts +87 -0
  178. package/src/openbao/remount-wire.ts +112 -0
  179. package/src/openbao/role-reconcile.test.ts +104 -0
  180. package/src/openbao/role-reconcile.ts +84 -0
@@ -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.
@@ -18,6 +18,13 @@ export interface BaoAuthMethodProps {
18
18
  defaultLeaseTtl?: string;
19
19
  /** Max lease TTL, e.g. `8760h`. */
20
20
  maxLeaseTtl?: string;
21
+ /**
22
+ * The path this method lives at NOW, when `path` is where it should move to — `sys/remount`
23
+ * from `auth/<remountFrom>`, keeping every role. Without it a changed `path` fails the plan
24
+ * (mount-move.ts). ⚠️ Every token issued through the method is revoked by the move, and login
25
+ * URLs, policies and MFA enforcements by accessor are not rewritten (the accessor is kept).
26
+ */
27
+ remountFrom?: string;
21
28
  }
22
29
 
23
30
  export interface BaoAuthMethodAttributes {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Bao.AuthMethod's read and reconcile as plain effects — split from auth-method.ts so the move runs
3
+ * against a fake server. Same shape as mount-reconcile.ts; the one difference that matters is the
4
+ * `auth/` prefix sys/remount needs (vault/logical_system.go:1295-1310 refuses to move an auth mount
5
+ * to a non-auth path, and `token/` cannot move at all).
6
+ */
7
+ import * as Effect from 'effect/Effect';
8
+ import type * as HttpClient from 'effect/unstable/http/HttpClient';
9
+ import {
10
+ type BaoAuthMethodAttributes,
11
+ type BaoAuthMethodProps,
12
+ attributesOf,
13
+ authPath,
14
+ matches,
15
+ wantsTune,
16
+ } from './auth-method-form.ts';
17
+ import { enableAuthMethod, readAuthMethodData, tuneAuthMethod } from './auth-method-wire.ts';
18
+ import type { BaoError } from './bao-status.ts';
19
+ import { type MoveTarget, performMove, planMove } from './mount-move.ts';
20
+ import type { RemountError, RemountOptions } from './remount-wire.ts';
21
+
22
+ export const readMethod = (
23
+ props: BaoAuthMethodProps,
24
+ ): Effect.Effect<BaoAuthMethodAttributes | undefined, BaoError, HttpClient.HttpClient> =>
25
+ Effect.gen(function* () {
26
+ const live = yield* readAuthMethodData(props.path);
27
+ if (live === undefined) return undefined;
28
+ return attributesOf(props, live);
29
+ });
30
+
31
+ const moveTarget = (props: BaoAuthMethodProps): MoveTarget => ({
32
+ family: 'Bao.AuthMethod',
33
+ path: props.path,
34
+ read: (path) => readMethod({ ...props, path }),
35
+ type: props.type,
36
+ wire: (path) => `auth/${authPath(path)}`,
37
+ });
38
+
39
+ /** Converge one auth method. `stated` is the path in Alchemy's state, if any. */
40
+ export const reconcileAuthMethod = (
41
+ news: BaoAuthMethodProps,
42
+ stated: string | undefined,
43
+ options?: RemountOptions,
44
+ ): Effect.Effect<BaoAuthMethodAttributes, BaoError | RemountError, HttpClient.HttpClient> =>
45
+ Effect.gen(function* () {
46
+ const move = planMove('Bao.AuthMethod', stated, news.path, news.remountFrom);
47
+ if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
48
+ if (move.kind === 'move') yield* performMove(moveTarget(news), move.from, options);
49
+
50
+ let live = yield* readMethod(news);
51
+ if (live === undefined) {
52
+ yield* enableAuthMethod(news);
53
+ if (wantsTune(news)) yield* tuneAuthMethod(news);
54
+ } else if (!matches(live, news)) {
55
+ if (live.type !== news.type) {
56
+ return yield* Effect.die(
57
+ new Error(
58
+ `Bao.AuthMethod ${authPath(news.path)}: live type ${live.type} != ${news.type}. ` +
59
+ 'Auth method type is immutable — replace manually.',
60
+ ),
61
+ );
62
+ }
63
+ yield* tuneAuthMethod(news);
64
+ }
65
+ live = yield* readMethod(news);
66
+ if (live === undefined) {
67
+ return yield* Effect.die(
68
+ new Error(
69
+ `Bao.AuthMethod ${authPath(news.path)}: write returned no error but the method is still absent.`,
70
+ ),
71
+ );
72
+ }
73
+ return live;
74
+ });
@@ -7,6 +7,12 @@
7
7
  * ★ `defaultRemovalPolicy: 'retain'` — disabling a method destroys every role under it
8
8
  * and revokes tokens minted through those roles. Opt in with
9
9
  * `.pipe(RemovalPolicy.destroy())`.
10
+ *
11
+ * ★ REPLACE SEMANTICS (audited 2026-09-21, see REPLACE.md): `type` changed → plans `replace`, whose
12
+ * create half dies on the still-occupied path before any write, so the apply fails and nothing is
13
+ * disabled (auth-method-reconcile.ts); `path` changed → FAILS unless `remountFrom` names the old
14
+ * path (then an in-place move). It used to plan `update` and enable an empty method at the new
15
+ * path, exactly as Bao.Mount did.
10
16
  */
11
17
  import { Resource } from 'alchemy';
12
18
  import { isResolved } from 'alchemy/Diff';
@@ -16,17 +22,11 @@ import type * as HttpClient from 'effect/unstable/http/HttpClient';
16
22
  import {
17
23
  type BaoAuthMethodAttributes,
18
24
  type BaoAuthMethodProps,
19
- attributesOf,
20
- authPath,
21
25
  matches,
22
- wantsTune,
23
26
  } from './auth-method-form.ts';
24
- import {
25
- disableAuthMethod,
26
- enableAuthMethod,
27
- readAuthMethodData,
28
- tuneAuthMethod,
29
- } from './auth-method-wire.ts';
27
+ import { readMethod, reconcileAuthMethod } from './auth-method-reconcile.ts';
28
+ import { disableAuthMethod } from './auth-method-wire.ts';
29
+ import { planMove } from './mount-move.ts';
30
30
 
31
31
  export type { BaoAuthMethodAttributes, BaoAuthMethodProps };
32
32
 
@@ -42,13 +42,6 @@ export const BaoAuthMethod = Resource<BaoAuthMethod>('Bao.AuthMethod', {
42
42
  defaultRemovalPolicy: 'retain',
43
43
  });
44
44
 
45
- const readMethod = (props: BaoAuthMethodProps) =>
46
- Effect.gen(function* () {
47
- const live = yield* readAuthMethodData(props.path);
48
- if (live === undefined) return undefined;
49
- return attributesOf(props, live);
50
- });
51
-
52
45
  export const BaoAuthMethodProvider = () =>
53
46
  Provider.effect(
54
47
  BaoAuthMethod,
@@ -67,6 +60,14 @@ export const BaoAuthMethodProvider = () =>
67
60
 
68
61
  diff: Effect.fn(function* ({ news, output }) {
69
62
  if (output === undefined || !isResolved(news)) return undefined;
63
+ /**
64
+ * ⛔ A CHANGED PATH FAILS HERE unless `remountFrom` names the old one — the same trap
65
+ * Bao.Mount had: reading the new path finds nothing, plans `update`, and enables an
66
+ * EMPTY method beside the retained one, with every role left behind (mount-move.ts).
67
+ */
68
+ const move = planMove('Bao.AuthMethod', output.path, news.path, news.remountFrom);
69
+ if (move.kind === 'refuse') return yield* Effect.die(new Error(move.message));
70
+ if (move.kind === 'move') return { action: 'update' } as const;
70
71
  const live = yield* readMethod(news);
71
72
  if (live === undefined) return { action: 'update' } as const;
72
73
  /**
@@ -79,31 +80,9 @@ export const BaoAuthMethodProvider = () =>
79
80
  return { action: 'update' } as const;
80
81
  }),
81
82
 
82
- reconcile: Effect.fn(function* ({ news }) {
83
- let live = yield* readMethod(news);
84
- if (live === undefined) {
85
- yield* enableAuthMethod(news);
86
- if (wantsTune(news)) yield* tuneAuthMethod(news);
87
- } else if (!matches(live, news)) {
88
- if (live.type !== news.type) {
89
- return yield* Effect.die(
90
- new Error(
91
- `Bao.AuthMethod ${authPath(news.path)}: live type ${live.type} != ${news.type}. ` +
92
- 'Auth method type is immutable — replace manually.',
93
- ),
94
- );
95
- }
96
- yield* tuneAuthMethod(news);
97
- }
98
- live = yield* readMethod(news);
99
- if (live === undefined) {
100
- return yield* Effect.die(
101
- new Error(
102
- `Bao.AuthMethod ${authPath(news.path)}: write returned no error but the method is still absent.`,
103
- ),
104
- );
105
- }
106
- return live;
83
+ /** The body, and the move, live in auth-method-reconcile.ts. */
84
+ reconcile: Effect.fn(function* ({ news, output }) {
85
+ return yield* reconcileAuthMethod(news, output?.path);
107
86
  }),
108
87
 
109
88
  /**
@@ -5,10 +5,17 @@
5
5
  * sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
6
6
  * TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
7
7
  * read hands back that integer, which attributesOf renders as `90d` — the same role.
8
+ * And what the diff calls a rename (a `replace`): only a change the server would not fold away.
8
9
  */
9
10
  import assert from 'node:assert/strict';
10
11
  import { describe, it } from 'node:test';
11
- import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
12
+ import {
13
+ type BaoAuthRoleProps,
14
+ attributesOf,
15
+ isRename,
16
+ matches,
17
+ writeBody,
18
+ } from './auth-role-form.ts';
12
19
 
13
20
  const PROPS: BaoAuthRoleProps = {
14
21
  bindSecretId: true,
@@ -40,3 +47,11 @@ describe('auth role secret_id TTL', () => {
40
47
  assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
41
48
  });
42
49
  });
50
+
51
+ describe('auth role rename', () => {
52
+ it('is a rename only when the lower-cased names differ, as the server stores them', () => {
53
+ assert.equal(isRename('pve-node--node-a', 'pve-node--node-b'), true);
54
+ assert.equal(isRename('Host-Cert', 'host-cert'), false);
55
+ assert.equal(isRename('host-cert', 'host-cert'), false);
56
+ });
57
+ });
@@ -89,6 +89,17 @@ export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
89
89
  return body;
90
90
  };
91
91
 
92
+ /**
93
+ * Whether a changed `name` names a DIFFERENT role — the diff's `replace` question.
94
+ *
95
+ * ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
96
+ * v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key, :1537),
97
+ * so `Host` → `host` is the SAME role: a `replace` there would write it, then — under `destroy` —
98
+ * delete the old generation, which is that same role.
99
+ */
100
+ export const isRename = (from: string, to: string): boolean =>
101
+ from.toLowerCase() !== to.toLowerCase();
102
+
92
103
  const samePolicies = (want: readonly string[], have: readonly string[]) =>
93
104
  want.length === have.length && want.every((policy, index) => policy === have[index]);
94
105
 
@@ -17,6 +17,7 @@ import {
17
17
  type BaoAuthRoleAttributes,
18
18
  type BaoAuthRoleProps,
19
19
  attributesOf,
20
+ isRename,
20
21
  matches,
21
22
  readPath,
22
23
  writeBody,
@@ -66,6 +67,17 @@ export const BaoAuthRoleProvider = () =>
66
67
  */
67
68
  diff: Effect.fn(function* ({ news, output }) {
68
69
  if (output === undefined || !isResolved(news)) return undefined;
70
+ /**
71
+ * ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`.
72
+ * Until 2026-09-21 a new `name` read nothing, planned `update` and wrote the new role,
73
+ * leaving the old one live under no state record: its secret_ids kept logging in for
74
+ * their whole TTL, and no plan would ever mention it again (REPLACE.md).
75
+ * ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL STAYS LIVE — retain keeps the old
76
+ * generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
77
+ * opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
78
+ */
79
+ // ⚠️ Case-insensitively, because the server is — the ⚠️ on isRename in auth-role-form.ts.
80
+ if (isRename(output.name, news.name)) return { action: 'replace' } as const;
69
81
  const live = yield* readRole(news);
70
82
  if (live === undefined) return { action: 'update' } as const;
71
83
  if (matches(live, news)) return { action: 'noop' } as const;