@homeflare/alchemy 0.37.5 → 0.37.6
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/dist/openbao/auth-role-form.d.ts +0 -6
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role-identity.d.ts +11 -0
- package/dist/openbao/auth-role-identity.d.ts.map +1 -0
- package/dist/openbao/auth-role-wire.d.ts +23 -0
- package/dist/openbao/auth-role-wire.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/bao-http.d.ts +34 -13
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/distilled.d.ts +25 -0
- package/dist/openbao/distilled.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +5 -1
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -1
- package/dist/openbao/index.js +358 -305
- package/dist/openbao/index.js.map +13 -9
- package/dist/openbao/policy-identity.d.ts +12 -0
- package/dist/openbao/policy-identity.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +9 -8
- package/dist/openbao/policy-wire.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +5 -0
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/proxmox/index.js +2 -2
- package/dist/proxmox/index.js.map +3 -3
- package/dist/proxmox/node-network-wire.d.ts +14 -10
- package/dist/proxmox/node-network-wire.d.ts.map +1 -1
- package/docs/upstream-conformance.md +5 -3
- package/package.json +3 -2
- package/src/openbao/auth-role-form.test.ts +11 -9
- package/src/openbao/auth-role-form.ts +0 -19
- package/src/openbao/auth-role-identity.ts +24 -0
- package/src/openbao/auth-role-sdk.test.ts +155 -0
- package/src/openbao/auth-role-wire.test.ts +128 -0
- package/src/openbao/auth-role-wire.ts +89 -0
- package/src/openbao/auth-role.ts +9 -9
- package/src/openbao/bao-http.ts +9 -7
- package/src/openbao/distilled-retry.test.ts +164 -0
- package/src/openbao/distilled.test.ts +146 -0
- package/src/openbao/distilled.ts +88 -0
- package/src/openbao/fake-engines-roles.ts +9 -2
- package/src/openbao/policy-distilled.test.ts +98 -0
- package/src/openbao/policy-identity.ts +24 -0
- package/src/openbao/policy-rename.test.ts +2 -2
- package/src/openbao/policy-wire.test.ts +5 -5
- package/src/openbao/policy-wire.ts +31 -11
- package/src/openbao/policy.ts +10 -13
- package/src/openbao/rename.ts +17 -1
- package/src/proxmox/node-network-read-failure.test.ts +24 -0
- package/src/proxmox/node-network-wire.ts +15 -17
package/src/openbao/bao-http.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* ⚠️ THE STACK MUST PROVIDE `FetchHttpClient.layer`. alchemy.run.ts does, the way
|
|
12
12
|
* <estate>/proxmox/alchemy.run.ts does.
|
|
13
13
|
*/
|
|
14
|
+
import * as Cause from 'effect/Cause';
|
|
14
15
|
import * as Context from 'effect/Context';
|
|
15
16
|
import * as Effect from 'effect/Effect';
|
|
16
17
|
import * as Option from 'effect/Option';
|
|
@@ -65,7 +66,7 @@ export const BaoGate = Context.Reference<Semaphore.Semaphore>('homeflare/openbao
|
|
|
65
66
|
* ⚠️ THE CLI's DEFAULT CLIENT TIMEOUT (openbao v2.6.2 api/client.go:324). A hung agent or a server
|
|
66
67
|
* that accepts and never answers would otherwise hang the plan with no line saying why.
|
|
67
68
|
*/
|
|
68
|
-
const
|
|
69
|
+
export const BAO_TIMEOUT = '60 seconds';
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* Mesh in front of a remote OpenBao drops connections under Alchemy's unbounded
|
|
@@ -73,11 +74,12 @@ const TIMEOUT = '60 seconds';
|
|
|
73
74
|
* 2026-09-16 against bao.example.internal: a 585-role plan died mid-diff with
|
|
74
75
|
* `no response: (no errors given)` while the vault stayed unsealed.
|
|
75
76
|
*/
|
|
76
|
-
const retryTransport = <A, R>(effect: Effect.Effect<A,
|
|
77
|
+
export const retryTransport = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
77
78
|
Effect.retry(effect, {
|
|
78
79
|
schedule: Schedule.spaced('750 millis'),
|
|
79
80
|
times: 2,
|
|
80
|
-
while: (error
|
|
81
|
+
while: (error) =>
|
|
82
|
+
(error instanceof BaoError && error.status === 0) || Cause.isTimeoutError(error),
|
|
81
83
|
});
|
|
82
84
|
|
|
83
85
|
/**
|
|
@@ -87,7 +89,7 @@ const retryTransport = <A, R>(effect: Effect.Effect<A, BaoError, R>) =>
|
|
|
87
89
|
* true). `X-Vault-Token` is not on that list, so under any tracer the token would be exported as
|
|
88
90
|
* a span attribute. It is added for every call made here.
|
|
89
91
|
*/
|
|
90
|
-
const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
92
|
+
export const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
91
93
|
Effect.gen(function* () {
|
|
92
94
|
const names = yield* Headers.CurrentRedactedNames;
|
|
93
95
|
return yield* Effect.provideService(effect, Headers.CurrentRedactedNames, [
|
|
@@ -109,9 +111,9 @@ const redactingToken = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
|
|
109
111
|
* the request to http://localhost:80 — a silent wrong server, which is the one outcome this
|
|
110
112
|
* package exists to prevent. It also assumes the provided `HttpClient` is the fetch one.
|
|
111
113
|
*/
|
|
112
|
-
const overSocket =
|
|
114
|
+
export const overSocket =
|
|
113
115
|
(socket: string | undefined, method: string, path: string) =>
|
|
114
|
-
<A, R>(effect: Effect.Effect<A,
|
|
116
|
+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | BaoError, R> => {
|
|
115
117
|
if (socket === undefined) return effect;
|
|
116
118
|
if (typeof Bun === 'undefined') {
|
|
117
119
|
return Effect.fail(
|
|
@@ -148,7 +150,7 @@ export const baoCall = (
|
|
|
148
150
|
Effect.flatMap((response) =>
|
|
149
151
|
Effect.map(response.text, (text) => ({ status: response.status, text })),
|
|
150
152
|
),
|
|
151
|
-
Effect.timeout(
|
|
153
|
+
Effect.timeout(BAO_TIMEOUT),
|
|
152
154
|
// ⛔ `.message` ONLY: the HttpClientError message is the reason, method and URL. The error
|
|
153
155
|
// object itself holds the request, whose headers hold the token.
|
|
154
156
|
Effect.mapError((cause) => new BaoError(0, method, path, [cause.message])),
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { policiesReadAclPolicy } from '@distilled.cloud/openbao/policies';
|
|
3
|
+
import * as Effect from 'effect/Effect';
|
|
4
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
5
|
+
import { HttpClientError, TransportError } from 'effect/unstable/http/HttpClientError';
|
|
6
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
7
|
+
import { readAuthRole, writeAuthRole } from './auth-role-wire.ts';
|
|
8
|
+
import { BaoEnv } from './bao-http.ts';
|
|
9
|
+
import { BaoError } from './bao-status.ts';
|
|
10
|
+
import { runBaoRead } from './distilled.ts';
|
|
11
|
+
import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
|
|
12
|
+
|
|
13
|
+
const ROLE_PROPS = {
|
|
14
|
+
name: 'test',
|
|
15
|
+
tokenPolicies: ['default'],
|
|
16
|
+
tokenTtl: '15m',
|
|
17
|
+
tokenMaxTtl: '1h',
|
|
18
|
+
secretIdTtl: '2160h',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const TOKEN = 'fixture-retry-token';
|
|
22
|
+
const POLICY = { policy: 'path "test" {}' };
|
|
23
|
+
const ROLE = {
|
|
24
|
+
bind_secret_id: true,
|
|
25
|
+
secret_id_num_uses: 0,
|
|
26
|
+
secret_id_ttl: 0,
|
|
27
|
+
token_max_ttl: 3600,
|
|
28
|
+
token_policies: ['default'],
|
|
29
|
+
token_ttl: 900,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const runWith = <A, E>(
|
|
33
|
+
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
|
|
34
|
+
client: HttpClient.HttpClient,
|
|
35
|
+
) =>
|
|
36
|
+
Effect.runPromise(
|
|
37
|
+
effect.pipe(
|
|
38
|
+
Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid', BAO_TOKEN: TOKEN }),
|
|
39
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
40
|
+
),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
for (const [name, read, data] of [
|
|
44
|
+
['Policy', Effect.asVoid(readPolicy('test')), POLICY],
|
|
45
|
+
['AppRole', Effect.asVoid(readAuthRole('test')), ROLE],
|
|
46
|
+
] as const) {
|
|
47
|
+
test(`${name} retries a dropped read and then returns the live object`, async () => {
|
|
48
|
+
let attempts = 0;
|
|
49
|
+
const client = HttpClient.make((request) => {
|
|
50
|
+
attempts++;
|
|
51
|
+
expect(request.method).toBe('GET');
|
|
52
|
+
return attempts === 1
|
|
53
|
+
? Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }))
|
|
54
|
+
: Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data })));
|
|
55
|
+
});
|
|
56
|
+
await runWith(read, client);
|
|
57
|
+
expect(attempts).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
test('read transport retries stop after three attempts without exposing the token', async () => {
|
|
62
|
+
let attempts = 0;
|
|
63
|
+
const client = HttpClient.make((request) => {
|
|
64
|
+
attempts++;
|
|
65
|
+
return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
|
|
66
|
+
});
|
|
67
|
+
const error = await runWith(Effect.flip(readPolicy('test')), client);
|
|
68
|
+
expect(attempts).toBe(3);
|
|
69
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
70
|
+
expect(JSON.stringify(error)).not.toContain(TOKEN);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A FULL-REPLACE write sends the complete desired state on every attempt, so — unlike a
|
|
75
|
+
* create or a non-idempotent call — it is safe to replay after a transport-level failure.
|
|
76
|
+
* `runBaoWrite` restores the bounded retry `baoCall()` gave every call before the distilled
|
|
77
|
+
* SDK path landed with `Retry.none`.
|
|
78
|
+
*/
|
|
79
|
+
for (const [name, write] of [
|
|
80
|
+
['Policy', writePolicy('test', 'path "test" {}')],
|
|
81
|
+
['AppRole', writeAuthRole(ROLE_PROPS)],
|
|
82
|
+
] as const) {
|
|
83
|
+
test(`${name} write retries a dropped transport attempt and then succeeds`, async () => {
|
|
84
|
+
let attempts = 0;
|
|
85
|
+
const client = HttpClient.make((request) => {
|
|
86
|
+
attempts++;
|
|
87
|
+
expect(request.method).toBe('POST');
|
|
88
|
+
return attempts === 1
|
|
89
|
+
? Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }))
|
|
90
|
+
: Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 204 })));
|
|
91
|
+
});
|
|
92
|
+
await runWith(write, client);
|
|
93
|
+
expect(attempts).toBe(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test(`${name} write transport retries stop after three attempts without exposing the token`, async () => {
|
|
97
|
+
let attempts = 0;
|
|
98
|
+
const client = HttpClient.make((request) => {
|
|
99
|
+
attempts++;
|
|
100
|
+
return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
|
|
101
|
+
});
|
|
102
|
+
const error = await runWith(Effect.flip(write), client);
|
|
103
|
+
expect(attempts).toBe(3);
|
|
104
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
105
|
+
expect(JSON.stringify(error)).not.toContain(TOKEN);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test('a read timeout has a fresh bound on its next attempt', async () => {
|
|
110
|
+
let attempts = 0;
|
|
111
|
+
const client = HttpClient.make((request) => {
|
|
112
|
+
attempts++;
|
|
113
|
+
return attempts === 1
|
|
114
|
+
? Effect.never
|
|
115
|
+
: Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: POLICY })));
|
|
116
|
+
});
|
|
117
|
+
await runWith(runBaoRead(policiesReadAclPolicy({ name: 'test' }), '20 millis'), client);
|
|
118
|
+
expect(attempts).toBe(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Delete stays single-attempt: it is idempotent (a policy or role already gone is success,
|
|
123
|
+
* per policy-wire.ts / auth-role-wire.ts), but it is not a full-replace write, and this suite
|
|
124
|
+
* scopes the restored retry to the writes that carry the complete desired state on the wire.
|
|
125
|
+
*/
|
|
126
|
+
test('an uncertain delete is never replayed', async () => {
|
|
127
|
+
let attempts = 0;
|
|
128
|
+
const client = HttpClient.make((request) => {
|
|
129
|
+
attempts++;
|
|
130
|
+
return Effect.fail(new HttpClientError({ reason: new TransportError({ request }) }));
|
|
131
|
+
});
|
|
132
|
+
const error = await runWith(Effect.flip(deletePolicy('test')), client);
|
|
133
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
134
|
+
expect(attempts).toBe(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('typed denied reads are returned immediately without transport retries', async () => {
|
|
138
|
+
let attempts = 0;
|
|
139
|
+
const client = HttpClient.make((request) => {
|
|
140
|
+
attempts++;
|
|
141
|
+
return Effect.succeed(
|
|
142
|
+
HttpClientResponse.fromWeb(
|
|
143
|
+
request,
|
|
144
|
+
Response.json({ errors: ['permission denied'] }, { status: 403 }),
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
const error = await runWith(Effect.flip(readPolicy('test')), client);
|
|
149
|
+
expect(error).toMatchObject({ _tag: 'Forbidden' });
|
|
150
|
+
expect(attempts).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('SDK normalization accepts an address that already carries the API prefix', async () => {
|
|
154
|
+
let observed = '';
|
|
155
|
+
const client = HttpClient.make((request) => {
|
|
156
|
+
observed = request.url;
|
|
157
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ data: POLICY })));
|
|
158
|
+
});
|
|
159
|
+
await runWith(
|
|
160
|
+
readPolicy('test').pipe(Effect.provideService(BaoEnv, { BAO_ADDR: 'http://fake.invalid/v1/' })),
|
|
161
|
+
client,
|
|
162
|
+
);
|
|
163
|
+
expect(observed).toBe('http://fake.invalid/v1/sys/policies/acl/test');
|
|
164
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { policiesReadAclPolicy, policiesWriteAclPolicy } from '@distilled.cloud/openbao/policies';
|
|
6
|
+
import * as Effect from 'effect/Effect';
|
|
7
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
8
|
+
import * as Headers from 'effect/unstable/http/Headers';
|
|
9
|
+
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
10
|
+
import * as HttpClientResponse from 'effect/unstable/http/HttpClientResponse';
|
|
11
|
+
import { BaoEnv, BaoGate, MAX_IN_FLIGHT, baoRead } from './bao-http.ts';
|
|
12
|
+
import { runBao } from './distilled.ts';
|
|
13
|
+
import { fakeBao, run, runFailure, withFake } from './fake-bao.ts';
|
|
14
|
+
import { BaoError } from './bao-status.ts';
|
|
15
|
+
import { readPolicy } from './policy-wire.ts';
|
|
16
|
+
|
|
17
|
+
const OK = { json: { data: { policy: 'path "test" {}' } }, status: 200 };
|
|
18
|
+
|
|
19
|
+
test('SDK calls resolve BAO/VAULT values per call, retaining empty BAO precedence', async () => {
|
|
20
|
+
await withFake(
|
|
21
|
+
() => OK,
|
|
22
|
+
async (bao) => {
|
|
23
|
+
const env = {
|
|
24
|
+
VAULT_ADDR: bao.address,
|
|
25
|
+
VAULT_NAMESPACE: 'tenant',
|
|
26
|
+
VAULT_TOKEN: 'first',
|
|
27
|
+
BAO_TOKEN: '',
|
|
28
|
+
};
|
|
29
|
+
const read = readPolicy('test');
|
|
30
|
+
await run(env, read);
|
|
31
|
+
env.BAO_TOKEN = 'rotated';
|
|
32
|
+
await run(env, read);
|
|
33
|
+
expect(bao.seen[0]?.headers.has('x-vault-token')).toBe(false);
|
|
34
|
+
expect(bao.seen[1]?.headers.get('x-vault-token')).toBe('rotated');
|
|
35
|
+
expect(bao.seen[1]?.headers.get('x-vault-namespace')).toBe('tenant');
|
|
36
|
+
expect(bao.seen[1]?.headers.get('x-vault-request')).toBe('true');
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('SDK calls use the agent unix socket ahead of a direct address, without a token header', async () => {
|
|
42
|
+
const socket = join(tmpdir(), `distilled-bao-${process.pid}.sock`);
|
|
43
|
+
rmSync(socket, { force: true });
|
|
44
|
+
try {
|
|
45
|
+
await withFake(
|
|
46
|
+
() => OK,
|
|
47
|
+
async (bao) => {
|
|
48
|
+
await run(
|
|
49
|
+
{ BAO_ADDR: 'http://wrong.invalid', BAO_AGENT_ADDR: bao.address },
|
|
50
|
+
readPolicy('test'),
|
|
51
|
+
);
|
|
52
|
+
expect(bao.seen[0]?.path).toBe('/v1/sys/policies/acl/test');
|
|
53
|
+
expect(bao.seen[0]?.headers.has('x-vault-token')).toBe(false);
|
|
54
|
+
expect(bao.seen[0]?.headers.has('x-vault-namespace')).toBe(false);
|
|
55
|
+
},
|
|
56
|
+
socket,
|
|
57
|
+
);
|
|
58
|
+
} finally {
|
|
59
|
+
rmSync(socket, { force: true });
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('legacy and SDK calls share the same eight in-flight permits', async () => {
|
|
64
|
+
let active = 0;
|
|
65
|
+
let peak = 0;
|
|
66
|
+
await withFake(
|
|
67
|
+
async () => {
|
|
68
|
+
active++;
|
|
69
|
+
peak = Math.max(peak, active);
|
|
70
|
+
await Bun.sleep(15);
|
|
71
|
+
active--;
|
|
72
|
+
return OK;
|
|
73
|
+
},
|
|
74
|
+
async (bao) => {
|
|
75
|
+
await run(
|
|
76
|
+
{ BAO_ADDR: bao.address },
|
|
77
|
+
Effect.all(
|
|
78
|
+
Array.from({ length: 32 }, (_, i) =>
|
|
79
|
+
i % 2 === 0 ? Effect.asVoid(readPolicy(`p${i}`)) : Effect.asVoid(baoRead(`p${i}`)),
|
|
80
|
+
),
|
|
81
|
+
{ concurrency: 'unbounded' },
|
|
82
|
+
),
|
|
83
|
+
);
|
|
84
|
+
expect(peak).toBe(MAX_IN_FLIGHT);
|
|
85
|
+
expect(bao.seen).toHaveLength(32);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a sent write is bounded, not retried, and releases its permit', async () => {
|
|
91
|
+
await withFake(
|
|
92
|
+
async ({ method }) => {
|
|
93
|
+
if (method === 'POST') {
|
|
94
|
+
await Bun.sleep(120);
|
|
95
|
+
return { status: 204 };
|
|
96
|
+
}
|
|
97
|
+
return OK;
|
|
98
|
+
},
|
|
99
|
+
async (bao) => {
|
|
100
|
+
const gate = Semaphore.makeUnsafe(1);
|
|
101
|
+
const write = runBao(
|
|
102
|
+
policiesWriteAclPolicy({ name: 'test', policy: 'path "test" {}' }),
|
|
103
|
+
'30 millis',
|
|
104
|
+
);
|
|
105
|
+
const error = await runFailure(
|
|
106
|
+
{ BAO_ADDR: bao.address },
|
|
107
|
+
write.pipe(Effect.provideService(BaoGate, gate)),
|
|
108
|
+
);
|
|
109
|
+
expect(error).toMatchObject({ _tag: 'TimeoutError' });
|
|
110
|
+
await run(
|
|
111
|
+
{ BAO_ADDR: bao.address },
|
|
112
|
+
readPolicy('test').pipe(Effect.provideService(BaoGate, gate)),
|
|
113
|
+
);
|
|
114
|
+
expect(bao.seen.filter(({ method }) => method === 'POST')).toHaveLength(1);
|
|
115
|
+
},
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('the SDK keeps x-vault-token in Effect trace redaction alongside default names', async () => {
|
|
120
|
+
let names: ReadonlyArray<RegExp | string> = [];
|
|
121
|
+
const client = HttpClient.make((request, _url, _signal, fiber) => {
|
|
122
|
+
names = fiber.getRef(Headers.CurrentRedactedNames);
|
|
123
|
+
return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(OK.json)));
|
|
124
|
+
});
|
|
125
|
+
await Effect.runPromise(
|
|
126
|
+
runBao(policiesReadAclPolicy({ name: 'test' })).pipe(
|
|
127
|
+
Effect.provideService(BaoEnv, {
|
|
128
|
+
BAO_ADDR: 'http://fake.invalid',
|
|
129
|
+
BAO_TOKEN: 'never-log-this',
|
|
130
|
+
}),
|
|
131
|
+
Effect.provideService(HttpClient.HttpClient, client),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
expect(names).toContain('x-vault-token');
|
|
135
|
+
expect(names).toContain('authorization');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('transport failures cannot serialize the request token after the fiber context unwinds', async () => {
|
|
139
|
+
const gone = fakeBao(() => OK);
|
|
140
|
+
gone.stop();
|
|
141
|
+
const token = 'fixture-private-bao-token';
|
|
142
|
+
const error = await runFailure({ BAO_ADDR: gone.address, BAO_TOKEN: token }, readPolicy('test'));
|
|
143
|
+
expect(error).toBeInstanceOf(BaoError);
|
|
144
|
+
expect(JSON.stringify(error)).not.toContain(token);
|
|
145
|
+
expect(String(error)).not.toContain(token);
|
|
146
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The SDK shares Bao's existing call-time environment and process-wide concurrency gate.
|
|
3
|
+
* Merely supplying HttpClient does not apply that gate: legacy calls acquire it in baoCall.
|
|
4
|
+
* ⛔ `runBao` ITSELF NEVER RETRIES: an interrupted call may already have reached the vault, and
|
|
5
|
+
* replaying a non-idempotent operation (a delete whose 404-is-success path already ran, a login,
|
|
6
|
+
* a credential issuance) could repeat a side effect. `runBaoRead` and `runBaoWrite` opt specific,
|
|
7
|
+
* known-idempotent call shapes back into the bounded transport retry baoCall always had.
|
|
8
|
+
*/
|
|
9
|
+
import { Credentials, normalizeBaseUrl } from '@distilled.cloud/openbao/Credentials';
|
|
10
|
+
import type { OpenBaoOpContext } from '@distilled.cloud/openbao/Protocol';
|
|
11
|
+
import * as Retry from '@distilled.cloud/openbao/Retry';
|
|
12
|
+
import type * as Cause from 'effect/Cause';
|
|
13
|
+
import type * as Duration from 'effect/Duration';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import * as Redacted from 'effect/Redacted';
|
|
16
|
+
import * as Semaphore from 'effect/Semaphore';
|
|
17
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
18
|
+
import { isHttpClientError } from 'effect/unstable/http/HttpClientError';
|
|
19
|
+
import { readBaoVariable, resolveAddress } from './bao-address.ts';
|
|
20
|
+
import {
|
|
21
|
+
BAO_TIMEOUT,
|
|
22
|
+
BaoEnv,
|
|
23
|
+
BaoGate,
|
|
24
|
+
overSocket,
|
|
25
|
+
redactingToken,
|
|
26
|
+
retryTransport,
|
|
27
|
+
} from './bao-http.ts';
|
|
28
|
+
import { BaoError } from './bao-status.ts';
|
|
29
|
+
|
|
30
|
+
export const runBao = <A, E>(
|
|
31
|
+
operation: Effect.Effect<A, E, OpenBaoOpContext>,
|
|
32
|
+
timeout: Duration.Input = BAO_TIMEOUT,
|
|
33
|
+
): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
34
|
+
Effect.gen(function* () {
|
|
35
|
+
const env = yield* BaoEnv;
|
|
36
|
+
const address = resolveAddress(env);
|
|
37
|
+
const token = readBaoVariable(env, 'BAO_TOKEN');
|
|
38
|
+
const gate = yield* BaoGate;
|
|
39
|
+
return yield* operation.pipe(
|
|
40
|
+
Effect.provideService(
|
|
41
|
+
Credentials,
|
|
42
|
+
Effect.succeed({
|
|
43
|
+
apiBaseUrl: normalizeBaseUrl(address.base),
|
|
44
|
+
...(address.namespace === undefined ? {} : { namespace: address.namespace }),
|
|
45
|
+
...(token === '' ? {} : { token: Redacted.make(token) }),
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
Retry.none,
|
|
49
|
+
// ⛔ HttpClientError holds the request, including X-Vault-Token. Trace redaction is
|
|
50
|
+
// fiber-local and gone when an error is serialized later; keep only its safe message,
|
|
51
|
+
// as baoCall does. SDK status/resource errors retain their original typed identity.
|
|
52
|
+
Effect.mapError((cause) =>
|
|
53
|
+
isHttpClientError(cause) ? new BaoError(0, 'SDK', 'operation', [cause.message]) : cause,
|
|
54
|
+
),
|
|
55
|
+
// ★ A queued call has not reached the server: acquire the shared permit outside the timeout.
|
|
56
|
+
Effect.timeout(timeout),
|
|
57
|
+
overSocket(address.socket, 'SDK', 'operation'),
|
|
58
|
+
redactingToken,
|
|
59
|
+
Semaphore.withPermits(gate, 1),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reads use the existing two transport retries (750 ms between attempts). Each attempt
|
|
65
|
+
* reacquires the shared permit and its own bounded timeout. Typed SDK status and decoding
|
|
66
|
+
* failures still propagate immediately; a read is always safe to replay because it never
|
|
67
|
+
* changes vault state.
|
|
68
|
+
*/
|
|
69
|
+
export const runBaoRead = <A, E>(
|
|
70
|
+
operation: Effect.Effect<A, E, OpenBaoOpContext>,
|
|
71
|
+
timeout: Duration.Input = BAO_TIMEOUT,
|
|
72
|
+
): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
73
|
+
retryTransport(runBao(operation, timeout));
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pre-PR `baoCall()` retried every call, writes included; that bound was lost when the
|
|
77
|
+
* distilled SDK path landed with `Retry.none` on `runBao`. Restore it for the writes where
|
|
78
|
+
* replaying is safe: a FULL-REPLACE, idempotent write (an OpenBao policy PUT, an AppRole
|
|
79
|
+
* write) sends the complete desired state every time, so retrying an uncertain transport
|
|
80
|
+
* failure converges on the same result rather than compounding a partial change. A create
|
|
81
|
+
* that mints a new identifier, or any operation whose second attempt could do something the
|
|
82
|
+
* first did not, must keep calling `runBao` directly and make one attempt.
|
|
83
|
+
*/
|
|
84
|
+
export const runBaoWrite = <A, E>(
|
|
85
|
+
operation: Effect.Effect<A, E, OpenBaoOpContext>,
|
|
86
|
+
timeout: Duration.Input = BAO_TIMEOUT,
|
|
87
|
+
): Effect.Effect<A, E | BaoError | Cause.TimeoutError, HttpClient.HttpClient> =>
|
|
88
|
+
retryTransport(runBao(operation, timeout));
|
|
@@ -48,10 +48,17 @@ const typed = (body: Stored): Stored => {
|
|
|
48
48
|
return out;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Every role engine, JWT auth config and MFA login enforcement.
|
|
53
|
+
* ★ AppRole preserves omitted knobs and supplies defaults on create (OpenBao 2.6.2
|
|
54
|
+
* path_role.go:1688-1721); this fixture must not turn a correct omitted SDK field into drift.
|
|
55
|
+
*/
|
|
52
56
|
export const wireRoles = (): Store =>
|
|
53
57
|
store<Stored>(
|
|
54
|
-
(
|
|
58
|
+
(path, body, before) =>
|
|
59
|
+
path.startsWith('auth/approle/role/')
|
|
60
|
+
? { bind_secret_id: true, secret_id_num_uses: 0, ...before, ...typed(body) }
|
|
61
|
+
: typed(body),
|
|
55
62
|
(path) => keyOf(path),
|
|
56
63
|
);
|
|
57
64
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import * as Effect from 'effect/Effect';
|
|
3
|
+
import { runFailure, withFake } from './fake-bao.ts';
|
|
4
|
+
import { aclPolicies, fakePolicyProviders } from './fake-engines.ts';
|
|
5
|
+
import { withFakeStack, writesOf } from './fake-stack.ts';
|
|
6
|
+
import { BaoPolicy } from './policy.ts';
|
|
7
|
+
import { judgePolicyRename } from './policy-identity.ts';
|
|
8
|
+
import { deletePolicy, readPolicy, writePolicy } from './policy-wire.ts';
|
|
9
|
+
|
|
10
|
+
for (const [status, tag] of [
|
|
11
|
+
[401, 'Unauthorized'],
|
|
12
|
+
[403, 'Forbidden'],
|
|
13
|
+
[503, 'ServiceUnavailable'],
|
|
14
|
+
] as const) {
|
|
15
|
+
test(`SDK policy ${status} failures propagate on every verb without retry`, async () => {
|
|
16
|
+
await withFake(
|
|
17
|
+
() => ({ status, json: { errors: ['refused'] } }),
|
|
18
|
+
async (bao) => {
|
|
19
|
+
const env = { BAO_ADDR: bao.address };
|
|
20
|
+
for (const op of [
|
|
21
|
+
Effect.asVoid(readPolicy('test')),
|
|
22
|
+
writePolicy('test', 'path "test" {}'),
|
|
23
|
+
deletePolicy('test'),
|
|
24
|
+
]) {
|
|
25
|
+
expect(await runFailure(env, op)).toMatchObject({ _tag: tag });
|
|
26
|
+
}
|
|
27
|
+
expect(bao.seen.map(({ method }) => method)).toEqual(['GET', 'POST', 'DELETE']);
|
|
28
|
+
},
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('SDK policy refuses null, non-string, and missing policy text on a successful read', async () => {
|
|
34
|
+
for (const data of [null, {}, { policy: 42 }]) {
|
|
35
|
+
await withFake(
|
|
36
|
+
() => ({ status: 200, json: { data } }),
|
|
37
|
+
async (bao) => {
|
|
38
|
+
const error = await runFailure({ BAO_ADDR: bao.address }, readPolicy('test'));
|
|
39
|
+
expect(String(error)).toContain('no data.policy');
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('rename checks preserve an occupied empty policy and propagate denied SDK reads', async () => {
|
|
46
|
+
await withFake(
|
|
47
|
+
() => ({ status: 200, json: { data: { policy: '' } } }),
|
|
48
|
+
async (bao) => {
|
|
49
|
+
await expect(
|
|
50
|
+
runFailure(
|
|
51
|
+
{ BAO_ADDR: bao.address },
|
|
52
|
+
judgePolicyRename({ name: 'old' }, { name: 'new' }, undefined),
|
|
53
|
+
),
|
|
54
|
+
).rejects.toThrow('already exists');
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
await withFake(
|
|
58
|
+
() => ({ status: 403, json: { errors: ['refused'] } }),
|
|
59
|
+
async (bao) => {
|
|
60
|
+
expect(
|
|
61
|
+
await runFailure(
|
|
62
|
+
{ BAO_ADDR: bao.address },
|
|
63
|
+
judgePolicyRename({ name: 'old' }, { name: 'new' }, undefined),
|
|
64
|
+
),
|
|
65
|
+
).toMatchObject({ _tag: 'Forbidden' });
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('policy adoption and next deploy write nothing, while live drift writes one SDK POST', async () => {
|
|
71
|
+
const store = aclPolicies();
|
|
72
|
+
const path = 'sys/policies/acl/test';
|
|
73
|
+
store.live.set(path, {
|
|
74
|
+
name: 'test',
|
|
75
|
+
policy: 'path "/policies/test/a.hcl" {\n capabilities = ["read"]\n}',
|
|
76
|
+
});
|
|
77
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
78
|
+
const body = BaoPolicy('Policy', { name: 'test', fragments: '/policies/test' });
|
|
79
|
+
expect(await stack.deploy(body, { adopt: true })).toEqual({ Policy: 'adopted' });
|
|
80
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
81
|
+
expect(await stack.deploy(body)).toEqual({ Policy: 'noop' });
|
|
82
|
+
store.live.set(path, { name: 'test', policy: 'path "drift" {}' });
|
|
83
|
+
bao.seen.length = 0;
|
|
84
|
+
expect(await stack.deploy(body)).toEqual({ Policy: 'update' });
|
|
85
|
+
expect(writesOf(bao.seen)).toEqual(['POST /v1/sys/policies/acl/test']);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a real empty policy is an existing unowned object, never a first-time create', async () => {
|
|
90
|
+
const store = aclPolicies();
|
|
91
|
+
store.live.set('sys/policies/acl/test', { name: 'test', policy: '' });
|
|
92
|
+
await withFakeStack(fakePolicyProviders, store, async (stack, bao) => {
|
|
93
|
+
await expect(
|
|
94
|
+
stack.deploy(BaoPolicy('Policy', { name: 'test', fragments: '/policies/test' })),
|
|
95
|
+
).rejects.toThrow('not owned');
|
|
96
|
+
expect(writesOf(bao.seen)).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { policyExists, policyPath } from './policy-wire.ts';
|
|
2
|
+
import { declaredString, judgeMoveWith, policyKey } from './rename.ts';
|
|
3
|
+
import { nameIdentity } from './rename-identity.ts';
|
|
4
|
+
|
|
5
|
+
/** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
|
|
6
|
+
export const policyIdentity = nameIdentity<{ readonly name: string }>(
|
|
7
|
+
'Bao.Policy',
|
|
8
|
+
policyPath,
|
|
9
|
+
policyKey,
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
/** The occupied-name guard must use the same SDK and refusal semantics as every other read. */
|
|
13
|
+
export const judgePolicyRename = (
|
|
14
|
+
olds: unknown,
|
|
15
|
+
news: unknown,
|
|
16
|
+
output: { readonly name: string } | undefined,
|
|
17
|
+
) =>
|
|
18
|
+
judgeMoveWith(
|
|
19
|
+
'Bao.Policy',
|
|
20
|
+
output === undefined ? declaredString(olds, 'name') : output.name,
|
|
21
|
+
declaredString(news, 'name'),
|
|
22
|
+
policyExists,
|
|
23
|
+
policyKey,
|
|
24
|
+
);
|
|
@@ -38,7 +38,7 @@ describe('Bao.Policy rename, through the engine', () => {
|
|
|
38
38
|
Policy: 'replace',
|
|
39
39
|
});
|
|
40
40
|
assert.deepEqual(writes(bao.seen), [
|
|
41
|
-
'
|
|
41
|
+
'POST /v1/sys/policies/acl/app-new',
|
|
42
42
|
'DELETE /v1/sys/policies/acl/app-old',
|
|
43
43
|
]);
|
|
44
44
|
assert.deepEqual(live(), ['app-new']);
|
|
@@ -101,7 +101,7 @@ describe('Bao.Policy rename, through the engine', () => {
|
|
|
101
101
|
stack.deploy(body('/policies/two')),
|
|
102
102
|
/identity moved from app-1 to app-2/,
|
|
103
103
|
);
|
|
104
|
-
assert.deepEqual(writes(bao.seen), ['
|
|
104
|
+
assert.deepEqual(writes(bao.seen), ['POST /v1/sys/policies/acl/upstream']);
|
|
105
105
|
assert.deepEqual(await stack.deploy(body('/policies/two')), {
|
|
106
106
|
Policy: 'replace',
|
|
107
107
|
Upstream: 'noop',
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import { describe, it } from 'node:test';
|
|
8
|
-
import {
|
|
8
|
+
import { Forbidden } from '@distilled.cloud/openbao/Errors';
|
|
9
9
|
import { sha256 } from './digest.ts';
|
|
10
10
|
import { run, runFailure, withFake } from './fake-bao.ts';
|
|
11
11
|
import { aclPolicies } from './fake-engines.ts';
|
|
@@ -19,7 +19,7 @@ const ASSEMBLED = 'path "kv/data/a" {\n capabilities = ["read"]\n}\n' + 'path "
|
|
|
19
19
|
describe('policy wire', () => {
|
|
20
20
|
it('reads a policy that was never written as absent', async () => {
|
|
21
21
|
await withFake(aclPolicies(), async (bao) => {
|
|
22
|
-
assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')),
|
|
22
|
+
assert.equal(await run({ BAO_ADDR: bao.address }, readPolicy('zz-new-policy')), undefined);
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
|
|
@@ -29,10 +29,11 @@ describe('policy wire', () => {
|
|
|
29
29
|
await withFake(aclPolicies(), async (bao) => {
|
|
30
30
|
const env = { BAO_ADDR: bao.address };
|
|
31
31
|
await run(env, writePolicy('app-llm', ASSEMBLED));
|
|
32
|
-
assert.equal(bao.seen[0]?.method, '
|
|
32
|
+
assert.equal(bao.seen[0]?.method, 'POST');
|
|
33
33
|
assert.equal(bao.seen[0]?.path, `${PREFIX}app-llm`);
|
|
34
34
|
assert.deepEqual(JSON.parse(bao.seen[0]?.body ?? ''), { policy: ASSEMBLED });
|
|
35
35
|
const live = await run(env, readPolicy('app-llm'));
|
|
36
|
+
assert.ok(live !== undefined);
|
|
36
37
|
assert.notEqual(live, ASSEMBLED, 'the fake strips the newline, as OpenBao does');
|
|
37
38
|
assert.equal(sha256(live), sha256(ASSEMBLED));
|
|
38
39
|
});
|
|
@@ -43,8 +44,7 @@ describe('policy wire', () => {
|
|
|
43
44
|
() => ({ json: { errors: ['permission denied'] }, status: 403 }),
|
|
44
45
|
async (bao) => {
|
|
45
46
|
const error = await runFailure({ BAO_ADDR: bao.address }, readPolicy('homeflare-admin'));
|
|
46
|
-
assert.ok(error instanceof
|
|
47
|
-
assert.equal(error.status, 403);
|
|
47
|
+
assert.ok(error instanceof Forbidden);
|
|
48
48
|
},
|
|
49
49
|
);
|
|
50
50
|
});
|