@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.
- package/README.md +17 -48
- package/dist/index.d.ts +1 -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 +23 -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-roles-expand.d.ts.map +1 -1
- 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 +1433 -305
- package/dist/openbao/index.js.map +34 -13
- 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/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.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/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -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 +87 -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-roles-expand.ts +2 -5
- 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/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- 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
|
-
|
|
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;
|