@homeflare/alchemy 0.3.1 → 0.4.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 +5 -6
- package/dist/openbao/auth-method-form.d.ts +43 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -0
- package/dist/openbao/auth-method-wire.d.ts +21 -0
- package/dist/openbao/auth-method-wire.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +20 -0
- package/dist/openbao/auth-method.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +1 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +367 -242
- package/dist/openbao/index.js.map +8 -5
- package/package.json +2 -2
- package/src/openbao/auth-method-form.test.ts +62 -0
- package/src/openbao/auth-method-form.ts +112 -0
- package/src/openbao/auth-method-wire.test.ts +127 -0
- package/src/openbao/auth-method-wire.ts +66 -0
- package/src/openbao/auth-method.ts +119 -0
- package/src/openbao/index.ts +1 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-method shape: path, type, optional description/TTLs. No secrets.
|
|
3
|
+
* The write bodies are what configure-engines POSTed for approle.
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
type BaoAuthMethodProps,
|
|
9
|
+
attributesOf,
|
|
10
|
+
authPath,
|
|
11
|
+
enableBody,
|
|
12
|
+
matches,
|
|
13
|
+
readPath,
|
|
14
|
+
tuneBody,
|
|
15
|
+
tunePath,
|
|
16
|
+
wantsTune,
|
|
17
|
+
} from './auth-method-form.ts';
|
|
18
|
+
|
|
19
|
+
const PROPS: BaoAuthMethodProps = {
|
|
20
|
+
description: 'Alchemy and CI machine login',
|
|
21
|
+
path: 'approle/',
|
|
22
|
+
type: 'approle',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('auth-method form', () => {
|
|
26
|
+
it('strips a trailing slash the way sys/auth keys are stored', () => {
|
|
27
|
+
assert.equal(authPath('approle/'), 'approle');
|
|
28
|
+
assert.equal(readPath('approle/'), 'sys/auth/approle');
|
|
29
|
+
assert.equal(tunePath('approle'), 'sys/auth/approle/tune');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('enables with type and description, the body configure-engines sent', () => {
|
|
33
|
+
assert.deepEqual(enableBody(PROPS), {
|
|
34
|
+
description: 'Alchemy and CI machine login',
|
|
35
|
+
type: 'approle',
|
|
36
|
+
});
|
|
37
|
+
assert.deepEqual(enableBody({ path: 'approle', type: 'approle' }), { type: 'approle' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('tunes only named fields', () => {
|
|
41
|
+
assert.deepEqual(tuneBody(PROPS), { description: 'Alchemy and CI machine login' });
|
|
42
|
+
assert.deepEqual(tuneBody({ defaultLeaseTtl: '768h', path: 'approle', type: 'approle' }), {
|
|
43
|
+
default_lease_ttl: '768h',
|
|
44
|
+
});
|
|
45
|
+
assert.equal(wantsTune({ path: 'approle', type: 'approle' }), false);
|
|
46
|
+
assert.equal(wantsTune(PROPS), true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reads live listing fields into attributes and matches the declaration', () => {
|
|
50
|
+
const live = {
|
|
51
|
+
config: { default_lease_ttl: 0, max_lease_ttl: 0 },
|
|
52
|
+
description: 'Alchemy and CI machine login',
|
|
53
|
+
type: 'approle',
|
|
54
|
+
};
|
|
55
|
+
const attrs = attributesOf(PROPS, live);
|
|
56
|
+
assert.equal(attrs.path, 'approle');
|
|
57
|
+
assert.equal(attrs.type, 'approle');
|
|
58
|
+
assert.equal(attrs.description, 'Alchemy and CI machine login');
|
|
59
|
+
assert.equal(matches(attrs, PROPS), true);
|
|
60
|
+
assert.equal(matches(attrs, { ...PROPS, type: 'userpass' }), false);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-method shape — path, type, description, lease TTLs. METADATA ONLY.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ NO SECRET IN PROPS. Enabling `approle` (or jwt/oidc) is a mount, not a credential.
|
|
5
|
+
* Role ids, OIDC client secrets, and secret_ids stay out of Alchemy state.
|
|
6
|
+
*/
|
|
7
|
+
import { sha256 } from './digest.ts';
|
|
8
|
+
import { mountPath, ttlSeconds } from './mount-form.ts';
|
|
9
|
+
|
|
10
|
+
export interface BaoAuthMethodProps {
|
|
11
|
+
/** Auth path without trailing slash, as `sys/auth/<path>` takes it. */
|
|
12
|
+
path: string;
|
|
13
|
+
/** Auth method type, e.g. `approle`, `jwt`, `oidc`. */
|
|
14
|
+
type: string;
|
|
15
|
+
/** Human description — sent on enable and on tune, as configure-engines does. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Default lease TTL, e.g. `768h`. */
|
|
18
|
+
defaultLeaseTtl?: string;
|
|
19
|
+
/** Max lease TTL, e.g. `8760h`. */
|
|
20
|
+
maxLeaseTtl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BaoAuthMethodAttributes {
|
|
24
|
+
path: string;
|
|
25
|
+
type: string;
|
|
26
|
+
description: string;
|
|
27
|
+
defaultLeaseTtl: string;
|
|
28
|
+
maxLeaseTtl: string;
|
|
29
|
+
/** SHA-256 of the managed fields — safe to persist; see policy.ts. */
|
|
30
|
+
digest: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Auth path without a trailing slash — listing keys are `${path}/`. */
|
|
34
|
+
export const authPath = (path: string) => mountPath(path);
|
|
35
|
+
|
|
36
|
+
const configOf = (live: Record<string, unknown>) =>
|
|
37
|
+
typeof live['config'] === 'object' && live['config'] !== null
|
|
38
|
+
? (live['config'] as Record<string, unknown>)
|
|
39
|
+
: {};
|
|
40
|
+
|
|
41
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
42
|
+
|
|
43
|
+
const ttlProp = (seconds: number | undefined, fallback = '') => {
|
|
44
|
+
if (seconds === undefined) return fallback;
|
|
45
|
+
if (seconds % 86400 === 0) return `${String(seconds / 86400)}d`;
|
|
46
|
+
if (seconds % 3600 === 0) return `${String(seconds / 3600)}h`;
|
|
47
|
+
if (seconds % 60 === 0) return `${String(seconds / 60)}m`;
|
|
48
|
+
return `${String(seconds)}s`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const attributesOf = (
|
|
52
|
+
props: BaoAuthMethodProps,
|
|
53
|
+
live: Record<string, unknown>,
|
|
54
|
+
): BaoAuthMethodAttributes => {
|
|
55
|
+
const config = configOf(live);
|
|
56
|
+
const attrs = {
|
|
57
|
+
path: authPath(props.path),
|
|
58
|
+
type: text(live['type']) || props.type,
|
|
59
|
+
description: text(live['description']),
|
|
60
|
+
defaultLeaseTtl: ttlProp(ttlSeconds(config['default_lease_ttl'])),
|
|
61
|
+
maxLeaseTtl: ttlProp(ttlSeconds(config['max_lease_ttl'])),
|
|
62
|
+
};
|
|
63
|
+
return { ...attrs, digest: sha256(JSON.stringify(attrs)) };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** `sys/auth/<path>` — read, enable and disable all address the method here. */
|
|
67
|
+
export const readPath = (path: string) => `sys/auth/${authPath(path)}`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ★ SAME TUNE ROUTE SHAPE AS SECRETS MOUNTS. `bao auth tune` hits
|
|
71
|
+
* `sys/auth/<path>/tune` (openbao v2.6.2 api/sys_auth.go TuneAuth).
|
|
72
|
+
*/
|
|
73
|
+
export const tunePath = (path: string) => `${readPath(path)}/tune`;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The enable body. configure-engines POSTed `{ type, description }` for approle.
|
|
77
|
+
*
|
|
78
|
+
* ⚠️ TTLs ARE NOT SENT HERE. reconcile tunes them after enable, same as Bao.Mount.
|
|
79
|
+
*/
|
|
80
|
+
export const enableBody = (props: BaoAuthMethodProps): Record<string, string> => {
|
|
81
|
+
const body: Record<string, string> = { type: props.type };
|
|
82
|
+
if (props.description !== undefined) body['description'] = props.description;
|
|
83
|
+
return body;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Tune body — only the fields the declaration names. An omitted field is left alone. */
|
|
87
|
+
export const tuneBody = (props: BaoAuthMethodProps): Record<string, string> => {
|
|
88
|
+
const body: Record<string, string> = {};
|
|
89
|
+
if (props.description !== undefined) body['description'] = props.description;
|
|
90
|
+
if (props.defaultLeaseTtl !== undefined) body['default_lease_ttl'] = props.defaultLeaseTtl;
|
|
91
|
+
if (props.maxLeaseTtl !== undefined) body['max_lease_ttl'] = props.maxLeaseTtl;
|
|
92
|
+
return body;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const sameTtl = (want: string | undefined, have: string) => {
|
|
96
|
+
if (want === undefined) return true;
|
|
97
|
+
const wantSeconds = ttlSeconds(want);
|
|
98
|
+
const haveSeconds = ttlSeconds(have);
|
|
99
|
+
return wantSeconds !== undefined && wantSeconds === haveSeconds;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** True when live already matches the declaration on every managed field. */
|
|
103
|
+
export const matches = (attributes: BaoAuthMethodAttributes, props: BaoAuthMethodProps) =>
|
|
104
|
+
attributes.type === props.type &&
|
|
105
|
+
(props.description === undefined || attributes.description === props.description) &&
|
|
106
|
+
sameTtl(props.defaultLeaseTtl, attributes.defaultLeaseTtl) &&
|
|
107
|
+
sameTtl(props.maxLeaseTtl, attributes.maxLeaseTtl);
|
|
108
|
+
|
|
109
|
+
export const wantsTune = (props: BaoAuthMethodProps) =>
|
|
110
|
+
props.description !== undefined ||
|
|
111
|
+
props.defaultLeaseTtl !== undefined ||
|
|
112
|
+
props.maxLeaseTtl !== undefined;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.AuthMethod's calls against a fake. A missing method is a 400, not a 404 —
|
|
3
|
+
* the same trap as secrets mounts (mount-wire.ts). Only the auth table may
|
|
4
|
+
* turn that 400 into "absent".
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import {
|
|
9
|
+
disableAuthMethod,
|
|
10
|
+
enableAuthMethod,
|
|
11
|
+
readAuthMethodData,
|
|
12
|
+
tuneAuthMethod,
|
|
13
|
+
} from './auth-method-wire.ts';
|
|
14
|
+
import { BaoError } from './bao-status.ts';
|
|
15
|
+
import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
|
|
16
|
+
|
|
17
|
+
const NO_AUTH = { json: { errors: ['No auth engine at approle-new/'] }, status: 400 };
|
|
18
|
+
const table = (...paths: string[]) => ({
|
|
19
|
+
json: { data: Object.fromEntries(paths.map((path) => [path, { type: 'approle' }])) },
|
|
20
|
+
status: 200,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const routes = (method: Reply, listing: Reply) => (seen: Seen) =>
|
|
24
|
+
seen.path === '/v1/sys/auth' ? listing : method;
|
|
25
|
+
|
|
26
|
+
const statusOf = async (error: Promise<unknown>) => {
|
|
27
|
+
const settled = await error;
|
|
28
|
+
assert.ok(settled instanceof BaoError);
|
|
29
|
+
return settled.status;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe('readAuthMethodData', () => {
|
|
33
|
+
it('reads a method that exists, without consulting the table', async () => {
|
|
34
|
+
const reply = { json: { data: { config: {}, type: 'approle' } }, status: 200 };
|
|
35
|
+
await withFake(routes(reply, table()), async (bao) => {
|
|
36
|
+
const data = await run({ BAO_ADDR: bao.address }, readAuthMethodData('approle'));
|
|
37
|
+
assert.equal(data?.['type'], 'approle');
|
|
38
|
+
assert.deepEqual(
|
|
39
|
+
bao.seen.map((seen) => seen.path),
|
|
40
|
+
['/v1/sys/auth/approle'],
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reads a missing method as absent once the table confirms OpenBao's 400", async () => {
|
|
46
|
+
await withFake(routes(NO_AUTH, table('token/', 'userpass/')), async (bao) => {
|
|
47
|
+
assert.equal(
|
|
48
|
+
await run({ BAO_ADDR: bao.address }, readAuthMethodData('approle-new/')),
|
|
49
|
+
undefined,
|
|
50
|
+
);
|
|
51
|
+
assert.deepEqual(
|
|
52
|
+
bao.seen.map((seen) => seen.path),
|
|
53
|
+
['/v1/sys/auth/approle-new', '/v1/sys/auth'],
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('keeps the 400 when the table lists the method after all', async () => {
|
|
59
|
+
await withFake(routes(NO_AUTH, table('approle-new/')), async (bao) => {
|
|
60
|
+
const error = runFailure({ BAO_ADDR: bao.address }, readAuthMethodData('approle-new'));
|
|
61
|
+
assert.equal(await statusOf(error), 400);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('keeps the 400 when the table read is refused', async () => {
|
|
66
|
+
const refused = { json: { errors: ['permission denied'] }, status: 403 };
|
|
67
|
+
await withFake(routes(NO_AUTH, refused), async (bao) => {
|
|
68
|
+
const error = runFailure({ BAO_ADDR: bao.address }, readAuthMethodData('approle-new'));
|
|
69
|
+
assert.equal(await statusOf(error), 400);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('fails a refused method read (403) without consulting the table', async () => {
|
|
74
|
+
const refused = { json: { errors: ['permission denied'] }, status: 403 };
|
|
75
|
+
await withFake(routes(refused, table()), async (bao) => {
|
|
76
|
+
const error = runFailure({ BAO_ADDR: bao.address }, readAuthMethodData('approle'));
|
|
77
|
+
assert.equal(await statusOf(error), 403);
|
|
78
|
+
assert.equal(bao.seen.length, 1);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('enable, tune, disable', () => {
|
|
84
|
+
it('sends the endpoints and bodies configure-engines sent', async () => {
|
|
85
|
+
await withFake(
|
|
86
|
+
() => ({ status: 204 }),
|
|
87
|
+
async (bao) => {
|
|
88
|
+
const env = { BAO_ADDR: bao.address };
|
|
89
|
+
const props = {
|
|
90
|
+
description: 'Alchemy and CI machine login',
|
|
91
|
+
path: 'approle/',
|
|
92
|
+
type: 'approle',
|
|
93
|
+
};
|
|
94
|
+
await run(env, enableAuthMethod(props));
|
|
95
|
+
await run(env, tuneAuthMethod(props));
|
|
96
|
+
await run(env, disableAuthMethod('approle'));
|
|
97
|
+
const calls = bao.seen.map((seen) => [seen.method, seen.path, seen.body]);
|
|
98
|
+
assert.deepEqual(calls, [
|
|
99
|
+
[
|
|
100
|
+
'POST',
|
|
101
|
+
'/v1/sys/auth/approle',
|
|
102
|
+
JSON.stringify({ type: 'approle', description: 'Alchemy and CI machine login' }),
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
'POST',
|
|
106
|
+
'/v1/sys/auth/approle/tune',
|
|
107
|
+
JSON.stringify({ description: 'Alchemy and CI machine login' }),
|
|
108
|
+
],
|
|
109
|
+
['DELETE', '/v1/sys/auth/approle', ''],
|
|
110
|
+
]);
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('fails a refused enable with OpenBao errors', async () => {
|
|
116
|
+
await withFake(
|
|
117
|
+
() => ({ json: { errors: ['path is already in use at approle/'] }, status: 400 }),
|
|
118
|
+
async (bao) => {
|
|
119
|
+
const error = runFailure(
|
|
120
|
+
{ BAO_ADDR: bao.address },
|
|
121
|
+
enableAuthMethod({ path: 'approle', type: 'approle' }),
|
|
122
|
+
);
|
|
123
|
+
assert.equal(await statusOf(error), 400);
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenBao calls Bao.AuthMethod makes — read one method, enable, tune, disable.
|
|
3
|
+
*
|
|
4
|
+
* ★ ENDPOINTS, READ FROM openbao v2.6.2 api/sys_auth.go — the client configure-engines
|
|
5
|
+
* used via curl:
|
|
6
|
+
* read GET sys/auth/<path>
|
|
7
|
+
* enable POST sys/auth/<path> — `bao auth enable`
|
|
8
|
+
* tune POST sys/auth/<path>/tune — `bao auth tune`
|
|
9
|
+
* disable DELETE sys/auth/<path> — `bao auth disable`
|
|
10
|
+
* table GET sys/auth
|
|
11
|
+
*/
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import {
|
|
14
|
+
type BaoAuthMethodProps,
|
|
15
|
+
authPath,
|
|
16
|
+
enableBody,
|
|
17
|
+
readPath,
|
|
18
|
+
tuneBody,
|
|
19
|
+
tunePath,
|
|
20
|
+
} from './auth-method-form.ts';
|
|
21
|
+
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
22
|
+
import { BaoError } from './bao-status.ts';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Confirm a method is absent from the auth table, or give back the error that asked.
|
|
26
|
+
*
|
|
27
|
+
* ⛔ A MISSING AUTH METHOD IS NOT A 404, IT IS A 400 — THE SAME TRAP AS SECRETS MOUNTS
|
|
28
|
+
* (mount-wire.ts). `handleReadAuth` answers an empty path with a bare error; the CLI
|
|
29
|
+
* therefore failed and never reached `auth enable`. REASONED FROM THE MOUNT ANALOG
|
|
30
|
+
* AND PINNED AGAINST A FAKE — a live missing-method read was not measured.
|
|
31
|
+
* ★ SO A 400 IS SETTLED BY GET sys/auth. Only a 200 table WITHOUT the `${path}/` key
|
|
32
|
+
* is absence. A listed method, a refused listing, or a 404 listing leaves the 400.
|
|
33
|
+
*/
|
|
34
|
+
const absentFromTable = (path: string, asked: BaoError) =>
|
|
35
|
+
baoRead('sys/auth').pipe(
|
|
36
|
+
Effect.mapError(
|
|
37
|
+
(listing) =>
|
|
38
|
+
new BaoError(asked.status, asked.method, asked.path, [
|
|
39
|
+
...asked.errors,
|
|
40
|
+
`sys/auth could not confirm the method is absent: ${listing.message}`,
|
|
41
|
+
]),
|
|
42
|
+
),
|
|
43
|
+
Effect.flatMap((table) =>
|
|
44
|
+
table !== undefined && !Object.hasOwn(table, `${authPath(path)}/`)
|
|
45
|
+
? Effect.succeed(undefined)
|
|
46
|
+
: Effect.fail(asked),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
/** One method's config (`type`, `description`, `config.*_lease_ttl`), or undefined when absent. */
|
|
51
|
+
export const readAuthMethodData = (path: string) =>
|
|
52
|
+
baoRead(readPath(path)).pipe(
|
|
53
|
+
Effect.catchIf(
|
|
54
|
+
(error) => error.status === 400,
|
|
55
|
+
(error) => absentFromTable(path, error),
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
export const enableAuthMethod = (props: BaoAuthMethodProps) =>
|
|
60
|
+
baoWrite('POST', readPath(props.path), enableBody(props));
|
|
61
|
+
|
|
62
|
+
export const tuneAuthMethod = (props: BaoAuthMethodProps) =>
|
|
63
|
+
baoWrite('POST', tunePath(props.path), tuneBody(props));
|
|
64
|
+
|
|
65
|
+
/** ⚠️ THERE IS NO FORCE. Disable is a plain DELETE, same as `bao auth disable`. */
|
|
66
|
+
export const disableAuthMethod = (path: string) => baoDelete(readPath(path));
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OpenBao auth method — path, type, description and lease TTLs.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ NO SECRET IN PROPS OR ATTRIBUTES — see policy.ts. This enables `approle` (or jwt/oidc)
|
|
5
|
+
* as a mount. Role ids, OIDC client secrets, and secret_ids are out of scope.
|
|
6
|
+
*
|
|
7
|
+
* ★ `defaultRemovalPolicy: 'retain'` — disabling a method destroys every role under it
|
|
8
|
+
* and revokes tokens minted through those roles. Opt in with
|
|
9
|
+
* `.pipe(RemovalPolicy.destroy())`.
|
|
10
|
+
*/
|
|
11
|
+
import { Resource } from 'alchemy';
|
|
12
|
+
import { isResolved } from 'alchemy/Diff';
|
|
13
|
+
import * as Provider from 'alchemy/Provider';
|
|
14
|
+
import * as Effect from 'effect/Effect';
|
|
15
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
16
|
+
import {
|
|
17
|
+
type BaoAuthMethodAttributes,
|
|
18
|
+
type BaoAuthMethodProps,
|
|
19
|
+
attributesOf,
|
|
20
|
+
authPath,
|
|
21
|
+
matches,
|
|
22
|
+
wantsTune,
|
|
23
|
+
} from './auth-method-form.ts';
|
|
24
|
+
import {
|
|
25
|
+
disableAuthMethod,
|
|
26
|
+
enableAuthMethod,
|
|
27
|
+
readAuthMethodData,
|
|
28
|
+
tuneAuthMethod,
|
|
29
|
+
} from './auth-method-wire.ts';
|
|
30
|
+
|
|
31
|
+
export type { BaoAuthMethodAttributes, BaoAuthMethodProps };
|
|
32
|
+
|
|
33
|
+
export interface BaoAuthMethod extends Resource<
|
|
34
|
+
'Bao.AuthMethod',
|
|
35
|
+
BaoAuthMethodProps,
|
|
36
|
+
BaoAuthMethodAttributes,
|
|
37
|
+
never,
|
|
38
|
+
HttpClient.HttpClient
|
|
39
|
+
> {}
|
|
40
|
+
|
|
41
|
+
export const BaoAuthMethod = Resource<BaoAuthMethod>('Bao.AuthMethod', {
|
|
42
|
+
defaultRemovalPolicy: 'retain',
|
|
43
|
+
});
|
|
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
|
+
export const BaoAuthMethodProvider = () =>
|
|
53
|
+
Provider.effect(
|
|
54
|
+
BaoAuthMethod,
|
|
55
|
+
Effect.succeed(
|
|
56
|
+
BaoAuthMethod.Provider.of({
|
|
57
|
+
/**
|
|
58
|
+
* ⛔ `bao auth list` IS NOT A LIST OF THINGS THIS OWNS. `token/` is always
|
|
59
|
+
* present. Returning it would invite Alchemy to adopt — and then disable —
|
|
60
|
+
* methods it never created.
|
|
61
|
+
*/
|
|
62
|
+
list: () => Effect.succeed([]),
|
|
63
|
+
|
|
64
|
+
read: Effect.fn(function* ({ olds }) {
|
|
65
|
+
return yield* readMethod(olds);
|
|
66
|
+
}),
|
|
67
|
+
|
|
68
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
69
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
70
|
+
const live = yield* readMethod(news);
|
|
71
|
+
if (live === undefined) return { action: 'update' } as const;
|
|
72
|
+
/**
|
|
73
|
+
* ⛔ A CHANGED `type` IS A REPLACE. Delete is an auth disable — every role
|
|
74
|
+
* under the method dies. `retain` guards both orphan-delete and replace
|
|
75
|
+
* (same Alchemy Apply.ts path Bao.Mount documents).
|
|
76
|
+
*/
|
|
77
|
+
if (live.type !== news.type) return { action: 'replace' } as const;
|
|
78
|
+
if (matches(live, news)) return { action: 'noop' } as const;
|
|
79
|
+
return { action: 'update' } as const;
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
83
|
+
let live = yield* readMethod(news);
|
|
84
|
+
if (live === undefined) {
|
|
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;
|
|
107
|
+
}),
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ⛔ DISABLING A METHOD DESTROYS EVERY ROLE UNDER IT. Idempotent as Alchemy
|
|
111
|
+
* requires — already gone is success — but retain is the default for a reason.
|
|
112
|
+
*/
|
|
113
|
+
delete: Effect.fn(function* ({ output }) {
|
|
114
|
+
yield* disableAuthMethod(output.path);
|
|
115
|
+
return undefined;
|
|
116
|
+
}),
|
|
117
|
+
}),
|
|
118
|
+
),
|
|
119
|
+
);
|
package/src/openbao/index.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
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
10
|
*/
|
|
11
|
+
export { BaoAuthMethod, BaoAuthMethodProvider } from './auth-method.ts';
|
|
11
12
|
export { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
12
13
|
export { BaoCloudflareRole, BaoCloudflareRoleProvider } from './cloudflare-role.ts';
|
|
13
14
|
export {
|