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