@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,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* appRoleLogin / revokeSelf against a fake, through the PROMISE wrappers a plain script calls — so
|
|
3
|
+
* the fetch client, the env override and the error type are all exercised, not just the Effect.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ Every call passes an explicit `env`; nothing here reads the shell's BAO_ADDR or BAO_TOKEN.
|
|
6
|
+
*/
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { describe, it } from 'node:test';
|
|
9
|
+
import { inspect } from 'node:util';
|
|
10
|
+
import { BaoLoginError, appRoleLogin, revokeSelf } from './approle-login.ts';
|
|
11
|
+
import { withFake } from './fake-bao.ts';
|
|
12
|
+
|
|
13
|
+
const ROLE = 'role-id-fixture';
|
|
14
|
+
const SECRET = 'fixturefixture';
|
|
15
|
+
const AUTH = {
|
|
16
|
+
auth: {
|
|
17
|
+
accessor: 'accessor-fixture',
|
|
18
|
+
client_token: 'token-fixture',
|
|
19
|
+
lease_duration: 900,
|
|
20
|
+
policies: ['default', 'host-cert'],
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const failure = async (pending: Promise<unknown>) => {
|
|
25
|
+
const error = await pending.then(
|
|
26
|
+
() => assert.fail('expected a rejection'),
|
|
27
|
+
(caught: unknown) => caught,
|
|
28
|
+
);
|
|
29
|
+
assert.ok(error instanceof BaoLoginError, 'rejects with BaoLoginError itself');
|
|
30
|
+
return error;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('appRoleLogin', () => {
|
|
34
|
+
it('logs in with no token header, whatever the environment holds', async () => {
|
|
35
|
+
await withFake(
|
|
36
|
+
() => ({ json: AUTH, status: 200 }),
|
|
37
|
+
async (bao) => {
|
|
38
|
+
const env = {
|
|
39
|
+
BAO_ADDR: bao.address,
|
|
40
|
+
BAO_NAMESPACE: 'example-ns',
|
|
41
|
+
BAO_TOKEN: 'operator-token',
|
|
42
|
+
VAULT_TOKEN: 'legacy-token',
|
|
43
|
+
};
|
|
44
|
+
const login = await appRoleLogin({ env, roleId: ROLE, secretId: SECRET });
|
|
45
|
+
const [seen] = bao.seen;
|
|
46
|
+
assert.equal(seen?.method, 'PUT');
|
|
47
|
+
assert.equal(seen?.path, '/v1/auth/approle/login');
|
|
48
|
+
assert.equal(seen?.body, JSON.stringify({ role_id: ROLE, secret_id: SECRET }));
|
|
49
|
+
assert.equal(seen?.headers.get('x-vault-token'), null);
|
|
50
|
+
assert.equal(seen?.headers.get('x-vault-namespace'), 'example-ns');
|
|
51
|
+
assert.equal(login.clientToken, 'token-fixture');
|
|
52
|
+
assert.equal(login.accessor, 'accessor-fixture');
|
|
53
|
+
assert.deepEqual(login.policies, ['default', 'host-cert']);
|
|
54
|
+
assert.equal(login.leaseDurationSeconds, 900);
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* ⚠️ THE PRINTS ARE THE POINT. The first version passed JSON and Object.keys checks and still
|
|
61
|
+
* printed the token in full under bun's console.log (approle-login-result.ts has the measure).
|
|
62
|
+
*/
|
|
63
|
+
it('keeps the token out of every print, JSON, spread and clone', async () => {
|
|
64
|
+
await withFake(
|
|
65
|
+
() => ({ json: AUTH, status: 200 }),
|
|
66
|
+
async (bao) => {
|
|
67
|
+
const env = { BAO_ADDR: bao.address };
|
|
68
|
+
const login = await appRoleLogin({ env, roleId: ROLE, secretId: SECRET });
|
|
69
|
+
const renderings = [
|
|
70
|
+
Bun.inspect(login),
|
|
71
|
+
inspect(login),
|
|
72
|
+
inspect(login, { showHidden: true }),
|
|
73
|
+
JSON.stringify(login),
|
|
74
|
+
JSON.stringify({ ...login }),
|
|
75
|
+
JSON.stringify(structuredClone(login)),
|
|
76
|
+
];
|
|
77
|
+
for (const text of renderings) assert.ok(!text.includes('token-fixture'), text);
|
|
78
|
+
assert.equal(Object.keys(login).includes('clientToken'), false);
|
|
79
|
+
assert.equal(login.accessor, 'accessor-fixture');
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('logs in on a named mount', async () => {
|
|
85
|
+
await withFake(
|
|
86
|
+
() => ({ json: AUTH, status: 200 }),
|
|
87
|
+
async (bao) => {
|
|
88
|
+
const env = { BAO_ADDR: bao.address };
|
|
89
|
+
await appRoleLogin({ env, mount: '/approle-hosts/', roleId: ROLE, secretId: SECRET });
|
|
90
|
+
assert.equal(bao.seen[0]?.path, '/v1/auth/approle-hosts/login');
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('refuses empty, missing and padded inputs without sending anything', async () => {
|
|
96
|
+
await withFake(
|
|
97
|
+
() => ({ json: AUTH, status: 200 }),
|
|
98
|
+
async (bao) => {
|
|
99
|
+
const env = { BAO_ADDR: bao.address };
|
|
100
|
+
const cases = [
|
|
101
|
+
{ roleId: '', secretId: SECRET },
|
|
102
|
+
{ roleId: ROLE, secretId: `${SECRET}\n` },
|
|
103
|
+
{ roleId: ROLE, secretId: undefined as unknown as string },
|
|
104
|
+
{ mount: '/', roleId: ROLE, secretId: SECRET },
|
|
105
|
+
// ⛔ The URL parser would resolve these onto another path; nothing may be sent.
|
|
106
|
+
{ mount: '../../sys/tools', roleId: ROLE, secretId: SECRET },
|
|
107
|
+
{ mount: 'approle?x=', roleId: ROLE, secretId: SECRET },
|
|
108
|
+
];
|
|
109
|
+
for (const input of cases) {
|
|
110
|
+
const error = await failure(appRoleLogin({ env, ...input }));
|
|
111
|
+
assert.equal(error.reason, 'input');
|
|
112
|
+
assert.ok(!error.message.includes(SECRET));
|
|
113
|
+
}
|
|
114
|
+
assert.equal(bao.seen.length, 0);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('redacts a secret_id OpenBao echoes back (approle path_login.go:291)', async () => {
|
|
120
|
+
const echoed = { errors: [`invalid secret_id "${SECRET}"`] };
|
|
121
|
+
await withFake(
|
|
122
|
+
() => ({ json: echoed, status: 400 }),
|
|
123
|
+
async (bao) => {
|
|
124
|
+
const env = { BAO_ADDR: bao.address };
|
|
125
|
+
const error = await failure(appRoleLogin({ env, roleId: ROLE, secretId: SECRET }));
|
|
126
|
+
assert.equal(error.reason, 'refused');
|
|
127
|
+
assert.equal(error.status, 400);
|
|
128
|
+
assert.deepEqual(error.errors, ['invalid secret_id "[redacted]"']);
|
|
129
|
+
assert.ok(!error.message.includes(SECRET));
|
|
130
|
+
},
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('describes a non-JSON error page by size, never by content', async () => {
|
|
135
|
+
const page = `<html>502 upstream said ${SECRET}</html>`;
|
|
136
|
+
await withFake(
|
|
137
|
+
() => ({ status: 502, text: page }),
|
|
138
|
+
async (bao) => {
|
|
139
|
+
const env = { BAO_ADDR: bao.address };
|
|
140
|
+
const error = await failure(appRoleLogin({ env, roleId: ROLE, secretId: SECRET }));
|
|
141
|
+
assert.equal(error.status, 502);
|
|
142
|
+
assert.match(error.message, /not an OpenBao error body/);
|
|
143
|
+
assert.ok(!error.message.includes('upstream'));
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('names a 2xx without a usable login — not JSON, no auth block, unfinished MFA', async () => {
|
|
149
|
+
const replies = [
|
|
150
|
+
{ json: { data: {} }, status: 200 },
|
|
151
|
+
{
|
|
152
|
+
json: { auth: { client_token: '', mfa_requirement: { mfa_request_id: 'x' } } },
|
|
153
|
+
status: 200,
|
|
154
|
+
},
|
|
155
|
+
// ⚠️ Some other web server at BAO_ADDR: a yes that is not a login, never `refused 200`.
|
|
156
|
+
{ status: 200, text: '<html>welcome</html>' },
|
|
157
|
+
];
|
|
158
|
+
const expected = [/no auth block/, /requires MFA/, /response 200: a success body/];
|
|
159
|
+
for (const [index, reply] of replies.entries()) {
|
|
160
|
+
await withFake(
|
|
161
|
+
() => reply,
|
|
162
|
+
async (bao) => {
|
|
163
|
+
const env = { BAO_ADDR: bao.address };
|
|
164
|
+
const error = await failure(appRoleLogin({ env, roleId: ROLE, secretId: SECRET }));
|
|
165
|
+
assert.equal(error.reason, 'response');
|
|
166
|
+
assert.match(error.message, expected[index] ?? /never/);
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('revokeSelf', () => {
|
|
174
|
+
it('revokes the token it was given, not the environment one', async () => {
|
|
175
|
+
await withFake(
|
|
176
|
+
() => ({ status: 204 }),
|
|
177
|
+
async (bao) => {
|
|
178
|
+
await revokeSelf('token-fixture', { BAO_ADDR: bao.address, BAO_TOKEN: 'operator-token' });
|
|
179
|
+
const [seen] = bao.seen;
|
|
180
|
+
assert.equal(seen?.method, 'PUT');
|
|
181
|
+
assert.equal(seen?.path, '/v1/auth/token/revoke-self');
|
|
182
|
+
assert.equal(seen?.headers.get('x-vault-token'), 'token-fixture');
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('refuses an empty token and redacts a refused one', async () => {
|
|
188
|
+
await withFake(
|
|
189
|
+
() => ({ json: { errors: ['permission denied for token-fixture'] }, status: 403 }),
|
|
190
|
+
async (bao) => {
|
|
191
|
+
const env = { BAO_ADDR: bao.address };
|
|
192
|
+
assert.equal((await failure(revokeSelf('', env))).reason, 'input');
|
|
193
|
+
assert.equal(bao.seen.length, 0);
|
|
194
|
+
const refused = await failure(revokeSelf('token-fixture', env));
|
|
195
|
+
assert.equal(refused.reason, 'refused');
|
|
196
|
+
assert.ok(!refused.message.includes('token-fixture'));
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppRole login and self-revoke for a plain Bun script — the one part of the openbao subpath that is
|
|
3
|
+
* not a Resource.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHY IT LIVES HERE. Wrapper scripts log in with a per-host AppRole and revoke the token on exit.
|
|
6
|
+
* Each one hand-rolling a `fetch` is how two of them end up disagreeing about BAO_ADDR versus
|
|
7
|
+
* BAO_AGENT_ADDR, or dropping the namespace header. This rides the transport every Bao.* family
|
|
8
|
+
* already uses: bao-address.ts resolution (BAO_ADDR, BAO_AGENT_ADDR, BAO_NAMESPACE, the VAULT_*
|
|
9
|
+
* twins, unix sockets), bao-http.ts's in-flight gate, timeout, transport retry and token
|
|
10
|
+
* redaction on spans, and bao-status.ts's status-first classification of the answer.
|
|
11
|
+
* ★ ONE IMPLEMENTATION, TWO SHAPES. `appRoleLoginEffect` and `revokeSelfEffect` are the
|
|
12
|
+
* implementation; `appRoleLogin` and `revokeSelf` run them on the fetch client for a caller with
|
|
13
|
+
* no Effect runtime. Both fail with BaoLoginError and nothing else.
|
|
14
|
+
*
|
|
15
|
+
* ⚠️ SHARING A secret_id ACROSS HOSTS THROUGH A CACHING PROXY SHARES THE TOKEN. Two hosts sending
|
|
16
|
+
* the same AppRole login through a caching OpenBao Proxy get the SAME cached token back, so two
|
|
17
|
+
* hosts become one identity in every audit line (recorded as a trap in the 2026-09-21 vault
|
|
18
|
+
* consolidation plan; not re-measured here). Give each host its own secret_id.
|
|
19
|
+
*/
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
22
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
|
+
import type { BaoEnvironment } from './bao-address.ts';
|
|
24
|
+
import { BaoEnv, baoCall } from './bao-http.ts';
|
|
25
|
+
import type { BaoError } from './bao-status.ts';
|
|
26
|
+
import {
|
|
27
|
+
BaoLoginError,
|
|
28
|
+
failureOf,
|
|
29
|
+
loginPath,
|
|
30
|
+
mountProblem,
|
|
31
|
+
redact,
|
|
32
|
+
refusal,
|
|
33
|
+
} from './approle-login-form.ts';
|
|
34
|
+
import { type AppRoleLogin, loginOf } from './approle-login-result.ts';
|
|
35
|
+
|
|
36
|
+
export type { BaoLoginFailure } from './approle-login-form.ts';
|
|
37
|
+
export type { AppRoleLogin } from './approle-login-result.ts';
|
|
38
|
+
export { BaoLoginError };
|
|
39
|
+
|
|
40
|
+
export interface AppRoleLoginInput {
|
|
41
|
+
/** The role_id. Not a secret on its own, but half of one — it is redacted from errors too. */
|
|
42
|
+
readonly roleId: string;
|
|
43
|
+
/** ⛔ The secret_id. Never logged, never in an error — see `redact`. */
|
|
44
|
+
readonly secretId: string;
|
|
45
|
+
/** The AppRole auth mount, e.g. `approle` (the default) — no `.`/`..` segments, `?` or `#`. */
|
|
46
|
+
readonly mount?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const LOGIN = 'OpenBao AppRole login';
|
|
50
|
+
const REVOKE = 'OpenBao revoke-self';
|
|
51
|
+
|
|
52
|
+
const failed =
|
|
53
|
+
(operation: string, secrets: readonly string[]) =>
|
|
54
|
+
(error: BaoError): BaoLoginError =>
|
|
55
|
+
new BaoLoginError(
|
|
56
|
+
failureOf(error.status),
|
|
57
|
+
error.status,
|
|
58
|
+
redact(error.errors, secrets),
|
|
59
|
+
operation,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Log in with a role_id and secret_id, under BaoEnv (process.env unless a caller provides one).
|
|
64
|
+
*
|
|
65
|
+
* ⛔ THE LOGIN SENDS NO TOKEN, WHATEVER THE ENVIRONMENT HOLDS. BAO_TOKEN is overridden with a
|
|
66
|
+
* PRESENT-BUT-EMPTY value for this one call, which bao-address.ts readBaoVariable treats as
|
|
67
|
+
* winning over VAULT_TOKEN, so headersFor omits X-Vault-Token entirely. A script that already
|
|
68
|
+
* had an operator token exported must not send it along with a machine login.
|
|
69
|
+
* ⚠️ A DROPPED TRANSPORT IS RETRIED TWICE (bao-http.ts retryTransport, status 0 only). If the first
|
|
70
|
+
* request reached OpenBao and only the answer was lost, the retry is a second login: one extra
|
|
71
|
+
* token that lives until its TTL, and — for a secret_id with `secret_id_num_uses` — one extra use.
|
|
72
|
+
* A single-use secret_id can therefore come back "invalid" on a flaky path.
|
|
73
|
+
* ★ `PUT`, THE METHOD api/logical.go:325 WriteWithContext SENDS, which is what the official Go helper
|
|
74
|
+
* calls (api/auth/approle/approle.go:146). The docs show POST; both are the update operation.
|
|
75
|
+
*/
|
|
76
|
+
export const appRoleLoginEffect = (
|
|
77
|
+
input: AppRoleLoginInput,
|
|
78
|
+
): Effect.Effect<AppRoleLogin, BaoLoginError, HttpClient.HttpClient> =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const problems = [
|
|
81
|
+
refusal('roleId', input.roleId),
|
|
82
|
+
refusal('secretId', input.secretId),
|
|
83
|
+
mountProblem(input.mount),
|
|
84
|
+
].filter((problem): problem is string => problem !== undefined);
|
|
85
|
+
if (problems.length > 0)
|
|
86
|
+
return yield* Effect.fail(new BaoLoginError('input', 0, problems, LOGIN));
|
|
87
|
+
const env = yield* BaoEnv;
|
|
88
|
+
const body = yield* baoCall('write', 'PUT', loginPath(input.mount), {
|
|
89
|
+
role_id: input.roleId,
|
|
90
|
+
secret_id: input.secretId,
|
|
91
|
+
}).pipe(
|
|
92
|
+
Effect.provideService(BaoEnv, { ...env, BAO_TOKEN: '' }),
|
|
93
|
+
Effect.mapError(failed(LOGIN, [input.roleId, input.secretId])),
|
|
94
|
+
);
|
|
95
|
+
const read = loginOf(body);
|
|
96
|
+
if ('problem' in read) {
|
|
97
|
+
return yield* Effect.fail(new BaoLoginError('response', 0, [read.problem], LOGIN));
|
|
98
|
+
}
|
|
99
|
+
return read.login;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Revoke `token` itself — what a wrapper runs on exit, so a login does not outlive its job.
|
|
104
|
+
*
|
|
105
|
+
* ★ `PUT auth/token/revoke-self`, READ FROM openbao v2.6.2 api/auth_token.go:318-330: the token
|
|
106
|
+
* revoked is the one in X-Vault-Token, and the body is empty. `token` rides in that header for
|
|
107
|
+
* this call only (BAO_TOKEN is overridden), whatever the environment holds.
|
|
108
|
+
* ⚠️ AN ALREADY-REVOKED OR EXPIRED TOKEN IS A FAILURE, NOT A NO-OP. OpenBao refuses a token it no
|
|
109
|
+
* longer knows (permission denied — not measured here), and a refusal from the wrong server looks
|
|
110
|
+
* the same; "I could not tell" stays an error. A wrapper that only wants best-effort cleanup on
|
|
111
|
+
* exit can catch BaoLoginError with reason `refused`.
|
|
112
|
+
* ⚠️ NEVER THROUGH AN AGENT OR PROXY IN `use_auto_auth_token = "force"` MODE. Force overwrites the
|
|
113
|
+
* request's token with the agent's own (bao-address.ts headersFor cites apiproxy.mdx), so this
|
|
114
|
+
* would revoke the AGENT's token. The estate's LAN proxy runs with no auto-auth at all.
|
|
115
|
+
*/
|
|
116
|
+
export const revokeSelfEffect = (
|
|
117
|
+
token: string,
|
|
118
|
+
): Effect.Effect<void, BaoLoginError, HttpClient.HttpClient> =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const problem = refusal('token', token);
|
|
121
|
+
if (problem !== undefined) {
|
|
122
|
+
return yield* Effect.fail(new BaoLoginError('input', 0, [problem], REVOKE));
|
|
123
|
+
}
|
|
124
|
+
const env = yield* BaoEnv;
|
|
125
|
+
yield* baoCall('write', 'PUT', 'auth/token/revoke-self').pipe(
|
|
126
|
+
Effect.provideService(BaoEnv, { ...env, BAO_TOKEN: token }),
|
|
127
|
+
Effect.mapError(failed(REVOKE, [token])),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* ★ `Effect.runPromise` REJECTS WITH THE FAILURE ITSELF on this Effect line (measured on
|
|
133
|
+
* effect 4.0.0-rc.115: the rejection is `instanceof` the failed class), so a script catches
|
|
134
|
+
* BaoLoginError directly.
|
|
135
|
+
*/
|
|
136
|
+
const runWith = <A>(
|
|
137
|
+
env: BaoEnvironment | undefined,
|
|
138
|
+
effect: Effect.Effect<A, BaoLoginError, HttpClient.HttpClient>,
|
|
139
|
+
): Promise<A> => {
|
|
140
|
+
const scoped = env === undefined ? effect : Effect.provideService(effect, BaoEnv, env);
|
|
141
|
+
return Effect.runPromise(scoped.pipe(Effect.provide(FetchHttpClient.layer)));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `appRoleLoginEffect` for a plain script. `env` defaults to `process.env`, read at call time.
|
|
146
|
+
*
|
|
147
|
+
* const login = await appRoleLogin({ roleId, secretId });
|
|
148
|
+
* try { … login.clientToken … } finally { await revokeSelf(login.clientToken); }
|
|
149
|
+
*/
|
|
150
|
+
export const appRoleLogin = (
|
|
151
|
+
input: AppRoleLoginInput & { readonly env?: BaoEnvironment },
|
|
152
|
+
): Promise<AppRoleLogin> => runWith(input.env, appRoleLoginEffect(input));
|
|
153
|
+
|
|
154
|
+
/** `revokeSelfEffect` for a plain script. `env` defaults to `process.env`, read at call time. */
|
|
155
|
+
export const revokeSelf = (token: string, env?: BaoEnvironment): Promise<void> =>
|
|
156
|
+
runWith(env, revokeSelfEffect(token));
|
|
@@ -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
|
/**
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.AuthRole's secret_id TTL at the 90-day length the per-host AppRoles use.
|
|
3
|
+
*
|
|
4
|
+
* ★ `2160h` IS WHAT GOES ON THE WIRE, AND OPENBAO ACCEPTS IT. MEASURED 2026-09-21 with a Go probe of
|
|
5
|
+
* sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
|
|
6
|
+
* TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import {
|
|
13
|
+
type BaoAuthRoleProps,
|
|
14
|
+
attributesOf,
|
|
15
|
+
isRename,
|
|
16
|
+
matches,
|
|
17
|
+
writeBody,
|
|
18
|
+
} from './auth-role-form.ts';
|
|
19
|
+
|
|
20
|
+
const PROPS: BaoAuthRoleProps = {
|
|
21
|
+
bindSecretId: true,
|
|
22
|
+
name: 'host-cert',
|
|
23
|
+
secretIdNumUses: 0,
|
|
24
|
+
secretIdTtl: '2160h',
|
|
25
|
+
tokenMaxTtl: '1h',
|
|
26
|
+
tokenPolicies: ['host-cert', 'default'],
|
|
27
|
+
tokenTtl: '15m',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('auth role secret_id TTL', () => {
|
|
31
|
+
it('writes 2160h as given', () => {
|
|
32
|
+
assert.equal(writeBody(PROPS)['secret_id_ttl'], '2160h');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads 7776000 seconds back as the same role', () => {
|
|
36
|
+
const live = {
|
|
37
|
+
bind_secret_id: true,
|
|
38
|
+
secret_id_num_uses: 0,
|
|
39
|
+
secret_id_ttl: 7776000,
|
|
40
|
+
token_max_ttl: 3600,
|
|
41
|
+
token_policies: ['default', 'host-cert'],
|
|
42
|
+
token_ttl: 900,
|
|
43
|
+
};
|
|
44
|
+
const attributes = attributesOf(PROPS, live);
|
|
45
|
+
assert.equal(attributes.secretIdTtl, '90d');
|
|
46
|
+
assert.equal(matches(attributes, PROPS), true);
|
|
47
|
+
assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
|
|
48
|
+
});
|
|
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;
|