@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,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,163 @@
|
|
|
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, in two batches.
|
|
4
|
+
Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
|
|
5
|
+
|
|
6
|
+
★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
|
|
7
|
+
in-memory state store against the fake engines in `fake-engines.ts`. `policy-rename.test.ts`,
|
|
8
|
+
`role-rename.test.ts` and `rename-occupied.test.ts` deploy, rename, and deploy again, then check
|
|
9
|
+
the plan and every call that reached the fake.
|
|
10
|
+
|
|
11
|
+
## What Alchemy does with each answer
|
|
12
|
+
|
|
13
|
+
- **`update`**: `reconcile` runs against the same resource. Only safe when the object's identity
|
|
14
|
+
(its path) is unchanged.
|
|
15
|
+
- **`replace`**: create-first by default. The new generation is created, dependents are updated in
|
|
16
|
+
the same graph, then the old generation is deleted (`Apply.ts`, delete-first comment near :1180).
|
|
17
|
+
★ That delete gets the **old** generation's attributes (`output: old.attr`), so a provider that
|
|
18
|
+
deletes by `output` deletes the old path. Measured: `PUT` of the new path, then `DELETE` of the
|
|
19
|
+
old one.
|
|
20
|
+
- **No answer** (`undefined`, which every diff returns while `news` holds a pending Output): the
|
|
21
|
+
engine plans `update` if any prop changed (`Plan.ts`, `havePropsChanged`). ⚠️ That is the bug
|
|
22
|
+
class below whenever the identity changed too.
|
|
23
|
+
- **`deleteFirst: true`**: tears the old one down first. It is only for identities that cannot
|
|
24
|
+
coexist, such as a unique physical name. No `Bao.*` provider uses it. A rename is a new API
|
|
25
|
+
path, which never collides with the old one. ⚠️ A mount or auth method whose `type` changes
|
|
26
|
+
keeps its path, so the two generations DO collide. It deliberately stays create-first, and the
|
|
27
|
+
create fails (see the table).
|
|
28
|
+
- **`retain`**, the default for every `Bao.*` family, **keeps the old generation of a replace**
|
|
29
|
+
(`Apply.ts:2164-2173`, "Retaining replaced resource"). ⚠️ So under the default, a replaced role
|
|
30
|
+
**stays live** and still admits logins. Opt into `RemovalPolicy.destroy()` where that matters
|
|
31
|
+
(per-host AppRoles), or remove it by hand.
|
|
32
|
+
|
|
33
|
+
## The bug class this audit found
|
|
34
|
+
|
|
35
|
+
⛔ **A changed identity planned `update`.** `diff` read the NEW path, found nothing, answered
|
|
36
|
+
`update`, and `reconcile` created the object at the new path. The old object stayed live with no
|
|
37
|
+
state record, so no later plan would mention it. For a mount this meant an **empty** new mount
|
|
38
|
+
beside the retained one that held every secret. For an AppRole it meant the old role's secret_ids
|
|
39
|
+
kept working for their whole TTL.
|
|
40
|
+
|
|
41
|
+
🔴 Reproduced through the engine on 2026-09-21 for `Bao.Policy` under `RemovalPolicy.destroy()`:
|
|
42
|
+
deploy `app-old`, redeclare it as `app-new`, deploy. The plan said `update`, and both policies
|
|
43
|
+
stayed live. `destroy` could not help, because nothing recorded the old one.
|
|
44
|
+
|
|
45
|
+
## Answers, by resource
|
|
46
|
+
|
|
47
|
+
| Resource | Identity change | Answer now | Other changes |
|
|
48
|
+
| ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
49
|
+
| `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). |
|
|
50
|
+
| `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
|
|
51
|
+
| `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
|
|
52
|
+
| `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
|
|
53
|
+
| `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
|
|
54
|
+
| `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
|
|
55
|
+
| `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
|
|
56
|
+
| `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
|
|
57
|
+
| `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
|
|
58
|
+
| `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. ⛔ **Fails the plan** onto a name that exists (below). | `update` (fragments digest) |
|
|
59
|
+
| `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. ⛔ **Fails the plan** onto a role that exists (below). | `update` |
|
|
60
|
+
| `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** onto a role that exists, or if the rename also changes `mintUser` unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
|
|
61
|
+
|
|
62
|
+
`hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
|
|
63
|
+
renames the role. With the logical id taken from the role name, as the README does, the old id
|
|
64
|
+
leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
|
|
65
|
+
`Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
|
|
66
|
+
|
|
67
|
+
## When the new identity is not known at plan
|
|
68
|
+
|
|
69
|
+
- **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
|
|
70
|
+
changing). `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` judge the identity props
|
|
71
|
+
before `isResolved(news)` (`rename.ts`), so the rename still plans `replace`. Measured.
|
|
72
|
+
- **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
|
|
73
|
+
unknown identity, because under `destroy` a replace onto the same path deletes what it just
|
|
74
|
+
wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
|
|
75
|
+
object, **refuses before any write**. The row is left `updating`, with the new props and the
|
|
76
|
+
old attributes. The next deploy sees both names and plans `replace`. Measured, for all three.
|
|
77
|
+
- `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
|
|
78
|
+
rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
|
|
79
|
+
|
|
80
|
+
## A move onto an object that already exists fails the plan
|
|
81
|
+
|
|
82
|
+
⛔ All three read the new path at plan (`judgeMove`, `rename.ts`) and refuse the move if anything
|
|
83
|
+
is there. 🔴 Measured before the guard: two policies that swapped names under
|
|
84
|
+
`RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
|
|
85
|
+
policy, and each old generation's delete then removed the name the other had just written. The
|
|
86
|
+
deploy was green, and both policies were gone, every grant revoked. Measured the same way:
|
|
87
|
+
|
|
88
|
+
- a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
|
|
89
|
+
stack deleted the name it moved onto;
|
|
90
|
+
- reverting a move whose new generation failed. The old generation still holds the name, and its
|
|
91
|
+
delete runs after the revert rewrites it. So the diff compares against the props of an
|
|
92
|
+
unfinished create or replacement when there are no attributes yet. Declaring the new name again
|
|
93
|
+
finishes the move instead.
|
|
94
|
+
|
|
95
|
+
⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
|
|
96
|
+
have been harmless. So would a move back onto a retained old generation. Move in two deploys
|
|
97
|
+
through a name nothing holds, or remove the target by hand first. A change of policy-name case, or
|
|
98
|
+
a trailing `/` on a mount, is the same object, so it is not a move and is never refused.
|
|
99
|
+
|
|
100
|
+
## What `retain` means for these three
|
|
101
|
+
|
|
102
|
+
Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
|
|
103
|
+
retained."). It is live and unmanaged from then on:
|
|
104
|
+
|
|
105
|
+
- **`Bao.Policy`**: the old policy keeps every grant, for every token, role and group that still
|
|
106
|
+
names it. Under `destroy` they lose those grants at the delete. ⚠️ Only a role that takes the
|
|
107
|
+
policy's `name` Output follows the rename in the same graph. A role that names the policy as a
|
|
108
|
+
literal string (`tokenPolicies: ['deploy']`) keeps the old name, so change it in the same PR.
|
|
109
|
+
- **`Bao.CloudflareRole`**: the old role still mints, for any token whose ACL reaches its
|
|
110
|
+
`creds/<name>`. Under `destroy` every consumer still minting from the old path fails. Tokens
|
|
111
|
+
already minted are leases and live to their own expiry either way.
|
|
112
|
+
- **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
|
|
113
|
+
plugin's delete leaves outstanding leases revocable (its own
|
|
114
|
+
`TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
|
|
115
|
+
|
|
116
|
+
★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
|
|
117
|
+
`declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
|
|
118
|
+
orphan delete, which `retain` also keeps live.
|
|
119
|
+
|
|
120
|
+
⚠️ A NEW declaration (a new logical id) of a name that already exists still takes that object over
|
|
121
|
+
and rewrites it: these families' `read` never answers `Unowned`, so Alchemy adopts it.
|
|
122
|
+
|
|
123
|
+
## Why the moves fail instead of replacing
|
|
124
|
+
|
|
125
|
+
- **Mount and auth path.** A `replace` would create an empty mount. Deleting the old one would
|
|
126
|
+
destroy every secret, and with the default `retain` the data would be stranded. `sys/remount`
|
|
127
|
+
is the only move that keeps the data: the mount keeps its UUID, and storage lives under the
|
|
128
|
+
UUID (`vault/mount.go:630-735`). ⛔ It **revokes every lease** under the old path
|
|
129
|
+
(`RevokePrefix`, :684), and it rewrites no policy that names the old path.
|
|
130
|
+
- **TOTP method name.** Every enrolled secret is keyed by the method id
|
|
131
|
+
(`entity.MFASecrets[id]`). The safe rename adds a second method, lists both on the enforcement
|
|
132
|
+
(any one passing is enough), enrols everyone, then drops the old method.
|
|
133
|
+
|
|
134
|
+
## Why a type change fails instead of replacing
|
|
135
|
+
|
|
136
|
+
The plan says `replace`, but the new generation reads the same path, finds the old type there,
|
|
137
|
+
and its reconcile dies with "type is immutable" before any write. So the deploy fails, and
|
|
138
|
+
nothing is disabled under either removal policy. Change a type by hand. ⛔ `deleteFirst` would
|
|
139
|
+
make it succeed, but under `destroy` it disables the mount first, and a mistyped `type` would
|
|
140
|
+
take every secret with it. (Corrected 2026-09-21: the first audit listed this as a clean
|
|
141
|
+
`replace`.)
|
|
142
|
+
|
|
143
|
+
## The plan's three exceptions, mapped
|
|
144
|
+
|
|
145
|
+
1. **`retain` resources orphan the old generation on a replace.** That applies to all of the
|
|
146
|
+
above. A mount is never replaced for a rename. It is moved (`remountFrom`), which works like
|
|
147
|
+
adopting it at the new path.
|
|
148
|
+
2. **Referents outside the graph.** These do not follow a replace: host credential files (a new
|
|
149
|
+
AppRole means a new role_id), login URLs and CLI `-path` flags naming an auth mount or role,
|
|
150
|
+
policies naming a mount path, and third-party OIDC clients holding a redirect URI. Rotate or
|
|
151
|
+
move them in the same PR.
|
|
152
|
+
3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
|
|
153
|
+
it fails on purpose instead (above).
|
|
154
|
+
|
|
155
|
+
## Open
|
|
156
|
+
|
|
157
|
+
- ⚠️ **Nine families still check the identity after `isResolved(news)`**: `Bao.AuthRole`,
|
|
158
|
+
`Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.JwtAuthConfig`, `Bao.MfaTotpMethod`,
|
|
159
|
+
`Bao.MfaLoginEnforcement`, `Bao.SshRole` and `Bao.Plugin`. A rename that lands in the same
|
|
160
|
+
deploy as any pending Output plans `update`, and none of their reconciles has the moved-update
|
|
161
|
+
guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
|
|
162
|
+
helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
|
|
163
|
+
reconcile re-plans the move from the stated path.)
|
|
@@ -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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 {
|
|
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
|
|
package/src/openbao/auth-role.ts
CHANGED
|
@@ -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;
|