@homeflare/alchemy 0.4.1 → 0.5.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 +57 -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/bao-http.d.ts +2 -0
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/index.d.ts +7 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +589 -305
- package/dist/openbao/index.js.map +16 -8
- package/dist/openbao/mount-path.d.ts +14 -0
- package/dist/openbao/mount-path.d.ts.map +1 -0
- 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/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/package.json +1 -1
- 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-role-form.test.ts +42 -0
- package/src/openbao/bao-http.ts +2 -0
- package/src/openbao/index.ts +13 -0
- package/src/openbao/mount-path.test.ts +23 -0
- package/src/openbao/mount-path.ts +25 -0
- package/src/openbao/mount-wire.test.ts +13 -0
- 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/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));
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
*/
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { describe, it } from 'node:test';
|
|
11
|
+
import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
|
|
12
|
+
|
|
13
|
+
const PROPS: BaoAuthRoleProps = {
|
|
14
|
+
bindSecretId: true,
|
|
15
|
+
name: 'host-cert',
|
|
16
|
+
secretIdNumUses: 0,
|
|
17
|
+
secretIdTtl: '2160h',
|
|
18
|
+
tokenMaxTtl: '1h',
|
|
19
|
+
tokenPolicies: ['host-cert', 'default'],
|
|
20
|
+
tokenTtl: '15m',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('auth role secret_id TTL', () => {
|
|
24
|
+
it('writes 2160h as given', () => {
|
|
25
|
+
assert.equal(writeBody(PROPS)['secret_id_ttl'], '2160h');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('reads 7776000 seconds back as the same role', () => {
|
|
29
|
+
const live = {
|
|
30
|
+
bind_secret_id: true,
|
|
31
|
+
secret_id_num_uses: 0,
|
|
32
|
+
secret_id_ttl: 7776000,
|
|
33
|
+
token_max_ttl: 3600,
|
|
34
|
+
token_policies: ['default', 'host-cert'],
|
|
35
|
+
token_ttl: 900,
|
|
36
|
+
};
|
|
37
|
+
const attributes = attributesOf(PROPS, live);
|
|
38
|
+
assert.equal(attributes.secretIdTtl, '90d');
|
|
39
|
+
assert.equal(matches(attributes, PROPS), true);
|
|
40
|
+
assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
|
|
41
|
+
});
|
|
42
|
+
});
|
package/src/openbao/bao-http.ts
CHANGED
|
@@ -190,6 +190,8 @@ export const baoRead = (path: string) =>
|
|
|
190
190
|
* ⚠️ ROLE BODIES STAY ALL-STRING ON PURPOSE. `bao write k=v` put every value in the JSON body as a
|
|
191
191
|
* string — MEASURED with `-output-curl-string`, see ssh-role-form.ts — and the form files still
|
|
192
192
|
* build exactly that body, so the server parses the same request it was always sent.
|
|
193
|
+
* ⛔ EXCEPT A MAP FIELD: sdk v2.6.2 refuses a string for a TypeMap with a 400, so Bao.SshRole
|
|
194
|
+
* sends its two maps as objects (the ⛔ on mapValue in ssh-role-form.ts, measured 2026-09-21).
|
|
193
195
|
*/
|
|
194
196
|
export const baoWrite = (
|
|
195
197
|
method: 'POST' | 'PUT',
|
package/src/openbao/index.ts
CHANGED
|
@@ -7,7 +7,18 @@
|
|
|
7
7
|
* every helper as API and make the next refactor a breaking change.
|
|
8
8
|
* ★ Anything unlisted is still reachable by path if you genuinely need it — that is a
|
|
9
9
|
* deliberate, visible act rather than an accident of barrelling.
|
|
10
|
+
* ★ THE APPROLE LOGIN IS HERE FOR SCRIPTS, NOT STACKS (approle-login.ts). A wrapper that logs in
|
|
11
|
+
* and revokes on exit needs it, and giving it the families' own transport is what keeps a script
|
|
12
|
+
* and a stack from disagreeing about which server BAO_ADDR means.
|
|
10
13
|
*/
|
|
14
|
+
export type { AppRoleLogin, AppRoleLoginInput, BaoLoginFailure } from './approle-login.ts';
|
|
15
|
+
export {
|
|
16
|
+
BaoLoginError,
|
|
17
|
+
appRoleLogin,
|
|
18
|
+
appRoleLoginEffect,
|
|
19
|
+
revokeSelf,
|
|
20
|
+
revokeSelfEffect,
|
|
21
|
+
} from './approle-login.ts';
|
|
11
22
|
export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
|
|
12
23
|
export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
13
24
|
export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
@@ -22,6 +33,8 @@ export type { RolesConfig, SurfaceSpec } from './cloudflare-roles-config.ts';
|
|
|
22
33
|
export { parseRolesConfig } from './cloudflare-roles-config.ts';
|
|
23
34
|
export { BaoMount, BaoMountProvider } from './mount.ts';
|
|
24
35
|
export { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
|
|
36
|
+
export type { BaoPluginProps, BaoPluginType } from './plugin.ts';
|
|
37
|
+
export { BaoPlugin, BaoPluginProvider } from './plugin.ts';
|
|
25
38
|
export { BaoPolicy, BaoPolicyProvider } from './policy.ts';
|
|
26
39
|
export { BaoProxmoxRole, BaoProxmoxRoleProvider } from './proxmox-role.ts';
|
|
27
40
|
export { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash trimming stays linear on caller input (CodeQL js/polynomial-redos).
|
|
3
|
+
*/
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { describe, it } from 'node:test';
|
|
6
|
+
import { trimSlashes, trimTrailingSlashes } from './mount-path.ts';
|
|
7
|
+
|
|
8
|
+
describe('mount-path', () => {
|
|
9
|
+
it('trims outer slashes and keeps inner ones', () => {
|
|
10
|
+
assert.equal(trimSlashes('/team/approle/'), 'team/approle');
|
|
11
|
+
assert.equal(trimSlashes('///'), '');
|
|
12
|
+
assert.equal(trimTrailingSlashes('/ssh-host//'), '/ssh-host');
|
|
13
|
+
assert.equal(trimTrailingSlashes('ssh'), 'ssh');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('handles a long run of slashes before other text in linear time', () => {
|
|
17
|
+
const run = '/'.repeat(200_000);
|
|
18
|
+
const started = performance.now();
|
|
19
|
+
assert.equal(trimTrailingSlashes(`${run}x`), `${run}x`);
|
|
20
|
+
assert.equal(trimSlashes(`${run}x${run}`), 'x');
|
|
21
|
+
assert.ok(performance.now() - started < 1_000, 'slash trimming took over a second');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash trimming for caller-supplied mount paths.
|
|
3
|
+
*
|
|
4
|
+
* ★ LOOPS, NOT `/^\/+|\/+$/g` OR `/\/+$/`. A trailing `\/+$` backtracks polynomially on a long run
|
|
5
|
+
* of `/` followed by anything else (CodeQL js/polynomial-redos), and mount paths are caller input.
|
|
6
|
+
* Both loops are linear in the input length.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** `text` without trailing `/`. */
|
|
10
|
+
export const trimTrailingSlashes = (text: string): string => {
|
|
11
|
+
let end = text.length;
|
|
12
|
+
while (end > 0 && text[end - 1] === '/') end -= 1;
|
|
13
|
+
return text.slice(0, end);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** `text` without leading or trailing `/`. */
|
|
17
|
+
export const trimSlashes = (text: string): string => {
|
|
18
|
+
let start = 0;
|
|
19
|
+
while (start < text.length && text[start] === '/') start += 1;
|
|
20
|
+
return trimTrailingSlashes(text.slice(start));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** SSH mount path without a trailing slash; `ssh` when the caller did not say. */
|
|
24
|
+
export const sshMountOf = (mount: string | undefined): string =>
|
|
25
|
+
trimTrailingSlashes(mount ?? 'ssh');
|
|
@@ -93,6 +93,19 @@ describe('enable, tune, disable', () => {
|
|
|
93
93
|
);
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
+
it('enables an ssh engine at any path — type is a free string, sent as given', async () => {
|
|
97
|
+
await withFake(
|
|
98
|
+
() => ({ status: 204 }),
|
|
99
|
+
async (bao) => {
|
|
100
|
+
await run({ BAO_ADDR: bao.address }, enableMount({ path: 'ssh-host/', type: 'ssh' }));
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
|
|
103
|
+
[['POST', '/v1/sys/mounts/ssh-host', JSON.stringify({ type: 'ssh' })]],
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
96
109
|
it('fails a refused enable with OpenBao errors', async () => {
|
|
97
110
|
await withFake(
|
|
98
111
|
() => ({ json: { errors: ['path is already in use at kv/'] }, status: 400 }),
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.Plugin's form: what a declaration may say, the body it registers with, and the equality a
|
|
3
|
+
* plan's `noop` rests on. The shapes are the v2.6.2 handler's (plugin-form.ts cites the lines).
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
type BaoPluginProps,
|
|
9
|
+
attributesOf,
|
|
10
|
+
matches,
|
|
11
|
+
problems,
|
|
12
|
+
registerBody,
|
|
13
|
+
resolve,
|
|
14
|
+
versionedPath,
|
|
15
|
+
} from './plugin-form.ts';
|
|
16
|
+
|
|
17
|
+
const SUM = '0123456789abcdef'.repeat(4);
|
|
18
|
+
const UNVERSIONED: BaoPluginProps = {
|
|
19
|
+
command: 'openbao-plugin-secrets-cloudflare',
|
|
20
|
+
name: 'openbao-plugin-secrets-cloudflare',
|
|
21
|
+
sha256: SUM.toUpperCase(),
|
|
22
|
+
type: 'secret',
|
|
23
|
+
};
|
|
24
|
+
const PROPS: BaoPluginProps = { ...UNVERSIONED, version: 'v0.1.2' };
|
|
25
|
+
|
|
26
|
+
describe('plugin form', () => {
|
|
27
|
+
it('resolves the defaults the register call stores, and lower-cases the sum', () => {
|
|
28
|
+
const form = resolve(UNVERSIONED);
|
|
29
|
+
assert.equal(form.sha256, SUM);
|
|
30
|
+
assert.deepEqual(form.args, []);
|
|
31
|
+
assert.equal(form.version, '');
|
|
32
|
+
assert.deepEqual(problems(resolve(PROPS)), []);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('refuses what the server would reject or store differently', () => {
|
|
36
|
+
const bad = (patch: Partial<BaoPluginProps>) => problems(resolve({ ...PROPS, ...patch }));
|
|
37
|
+
for (const name of ['a/b', '..x', '', 'has space']) assert.equal(bad({ name }).length, 1);
|
|
38
|
+
assert.equal(bad({ sha256: 'abc' }).length, 1);
|
|
39
|
+
for (const command of ['plugin --flag', 'sub/plugin', '/abs/plugin', '../plugin', './plugin']) {
|
|
40
|
+
assert.equal(bad({ command }).length, 1, command);
|
|
41
|
+
}
|
|
42
|
+
// ⚠️ Accepted by the server but stored canonicalised, so they would never read back equal.
|
|
43
|
+
for (const version of ['1.2.0', 'v1.2', 'v01.2.0']) assert.equal(bad({ version }).length, 1);
|
|
44
|
+
assert.ok(bad({ version: 'v1.2.0+builtin' }).length > 0);
|
|
45
|
+
assert.deepEqual(bad({ version: 'v1.2.0-rc.1' }), []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reads and deletes one version, and registers at the unversioned path', () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
versionedPath('secret', 'x', 'v1.0.0+meta'),
|
|
51
|
+
'sys/plugins/catalog/secret/x?version=v1.0.0%2Bmeta',
|
|
52
|
+
);
|
|
53
|
+
assert.equal(versionedPath('auth', 'x', ''), 'sys/plugins/catalog/auth/x');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('registers with the RegisterPluginInput fields and never env or a numeric type', () => {
|
|
57
|
+
assert.deepEqual(registerBody(resolve(PROPS)), {
|
|
58
|
+
command: PROPS.command,
|
|
59
|
+
sha256: SUM,
|
|
60
|
+
version: 'v0.1.2',
|
|
61
|
+
});
|
|
62
|
+
const withArgs = registerBody(resolve({ ...UNVERSIONED, args: ['--b', '--a'] }));
|
|
63
|
+
assert.deepEqual(withArgs, { args: ['--b', '--a'], command: PROPS.command, sha256: SUM });
|
|
64
|
+
assert.equal('env' in withArgs || 'type' in withArgs, false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('matches the live entry by value, args in order, and never a builtin', () => {
|
|
68
|
+
const form = resolve({ ...PROPS, args: ['--a', '--b'] });
|
|
69
|
+
const live = {
|
|
70
|
+
args: ['--a', '--b'],
|
|
71
|
+
builtin: false,
|
|
72
|
+
command: PROPS.command,
|
|
73
|
+
declarative: false,
|
|
74
|
+
name: PROPS.name,
|
|
75
|
+
oci: false,
|
|
76
|
+
sha256: SUM,
|
|
77
|
+
version: 'v0.1.2',
|
|
78
|
+
};
|
|
79
|
+
assert.equal(matches(attributesOf(form, live), form), true);
|
|
80
|
+
assert.equal(matches(attributesOf(form, { ...live, args: ['--b', '--a'] }), form), false);
|
|
81
|
+
assert.equal(matches(attributesOf(form, { ...live, sha256: 'f'.repeat(64) }), form), false);
|
|
82
|
+
assert.equal(matches(attributesOf(form, { ...live, builtin: true }), form), false);
|
|
83
|
+
const bare = resolve(UNVERSIONED);
|
|
84
|
+
assert.deepEqual(attributesOf(bare, { ...live, args: null, version: '' }).args, []);
|
|
85
|
+
});
|
|
86
|
+
});
|