@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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An OpenBao plugin catalog entry — `sys/plugins/catalog/<type>/<name>`: which binary in
|
|
3
|
+
* plugin_directory, at which SHA-256, may run as a plugin of that name. METADATA ONLY.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ IT REGISTERS; IT DOES NOT DELIVER. The binary must already sit in plugin_directory. Copying it
|
|
6
|
+
* there is a host step.
|
|
7
|
+
* ⚠️ A MISSING FILE FAILS THE WRITE; A WRONG SHA-256 DOES NOT. setInternal (plugin_catalog.go
|
|
8
|
+
* :1150-1230) resolves the path first, which fails loudly, but the run that would notice a
|
|
9
|
+
* checksum mismatch is only the version probe, and that is best-effort: it logs a warning and
|
|
10
|
+
* stores the entry anyway. The mismatch then surfaces when a mount first starts the plugin.
|
|
11
|
+
* ⛔ NO `env` PROP, AND THERE MUST NEVER BE ONE — see registerBody in plugin-form.ts. Alchemy keeps
|
|
12
|
+
* props and attributes UNENCRYPTED in its state store (policy.ts has the reasoning).
|
|
13
|
+
* ⚠️ A NEW SHA-256 DOES NOT RESTART A RUNNING PLUGIN. setInternal writes the catalog entry and
|
|
14
|
+
* reloads nothing, so mounts keep the old process until `sys/plugins/reload/backend` or a
|
|
15
|
+
* restart. Reloading is an operator step, not this resource.
|
|
16
|
+
*
|
|
17
|
+
* ★ `defaultRemovalPolicy: 'retain'`. Deregistering does not check whether a mount still runs the
|
|
18
|
+
* plugin (the ⛔ on deregisterPlugin in plugin-wire.ts), so a destroy would break that mount at its
|
|
19
|
+
* next start. Retain also means a version bump — a REPLACE, below — leaves the old version
|
|
20
|
+
* registered for mounts pinned to it; prune it by hand once nothing uses it.
|
|
21
|
+
*/
|
|
22
|
+
import { Resource } from 'alchemy';
|
|
23
|
+
import { isResolved } from 'alchemy/Diff';
|
|
24
|
+
import * as Provider from 'alchemy/Provider';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
27
|
+
import { type BaoPluginAttributes, type BaoPluginProps, matches, resolve } from './plugin-form.ts';
|
|
28
|
+
import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
|
|
29
|
+
import { deregisterPlugin } from './plugin-wire.ts';
|
|
30
|
+
|
|
31
|
+
export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
|
|
32
|
+
|
|
33
|
+
export interface BaoPlugin extends Resource<
|
|
34
|
+
'Bao.Plugin',
|
|
35
|
+
BaoPluginProps,
|
|
36
|
+
BaoPluginAttributes,
|
|
37
|
+
never,
|
|
38
|
+
HttpClient.HttpClient
|
|
39
|
+
> {}
|
|
40
|
+
|
|
41
|
+
export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
|
|
42
|
+
defaultRemovalPolicy: 'retain',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const BaoPluginProvider = () =>
|
|
46
|
+
Provider.effect(
|
|
47
|
+
BaoPlugin,
|
|
48
|
+
Effect.succeed(
|
|
49
|
+
BaoPlugin.Provider.of({
|
|
50
|
+
/**
|
|
51
|
+
* ⛔ THE CATALOG IS NOT A LIST OF THINGS THIS OWNS. It answers every builtin plugin and
|
|
52
|
+
* every hand registration. Returning them would invite Alchemy to adopt — and then
|
|
53
|
+
* deregister — plugins it never registered.
|
|
54
|
+
*/
|
|
55
|
+
list: () => Effect.succeed([]),
|
|
56
|
+
|
|
57
|
+
read: Effect.fn(function* ({ olds }) {
|
|
58
|
+
const found = yield* readEntry(resolve(olds));
|
|
59
|
+
return found?.attributes;
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
/** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
|
|
63
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
64
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
65
|
+
const form = resolve(news);
|
|
66
|
+
/**
|
|
67
|
+
* ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so changing one is a different entry,
|
|
68
|
+
* not an edit. Writing the new key would leave the old registration in place under a
|
|
69
|
+
* state record that no longer names it.
|
|
70
|
+
*/
|
|
71
|
+
if (
|
|
72
|
+
output.name !== form.name ||
|
|
73
|
+
output.type !== form.type ||
|
|
74
|
+
output.version !== form.version
|
|
75
|
+
) {
|
|
76
|
+
return { action: 'replace' } as const;
|
|
77
|
+
}
|
|
78
|
+
const found = yield* readEntry(form);
|
|
79
|
+
if (found === undefined) return { action: 'update' } as const;
|
|
80
|
+
return matches(found.attributes, form)
|
|
81
|
+
? ({ action: 'noop' } as const)
|
|
82
|
+
: ({ action: 'update' } as const);
|
|
83
|
+
}),
|
|
84
|
+
|
|
85
|
+
/** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
|
|
86
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
87
|
+
return yield* reconcilePlugin(news);
|
|
88
|
+
}),
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* ⛔ READ THE ⛔ ON deregisterPlugin FIRST: nothing checks that a mount still runs this.
|
|
92
|
+
* Idempotent as Alchemy requires — already gone is success on the server.
|
|
93
|
+
*/
|
|
94
|
+
delete: Effect.fn(function* ({ output }) {
|
|
95
|
+
yield* deregisterPlugin(output.type, output.name, output.version);
|
|
96
|
+
return undefined;
|
|
97
|
+
}),
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sys/remount and its status loop against a fake. The shapes pinned here are the CLI's own
|
|
3
|
+
* (api/sys_mounts.go:95-191): POST `{from, to}`, then poll until `success` or `failure`.
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import * as Effect from 'effect/Effect';
|
|
8
|
+
import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
|
|
9
|
+
import { RemountError, remountAndWait } from './remount-wire.ts';
|
|
10
|
+
|
|
11
|
+
const STARTED = { json: { data: { migration_id: 'm-1' }, warnings: ['queued'] }, status: 200 };
|
|
12
|
+
const status = (value: string): Reply => ({
|
|
13
|
+
json: {
|
|
14
|
+
data: {
|
|
15
|
+
migration_id: 'm-1',
|
|
16
|
+
migration_info: { source_mount: 'kv/', status: value, target_mount: 'kv-moved/' },
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
status: 200,
|
|
20
|
+
});
|
|
21
|
+
const FORGOTTEN: Reply = { json: { errors: [] }, status: 404 };
|
|
22
|
+
|
|
23
|
+
/** Answer the start, then each status read from `statuses` in order (the last one repeats). */
|
|
24
|
+
const script = (...statuses: Reply[]) => {
|
|
25
|
+
let polls = 0;
|
|
26
|
+
return (seen: Seen): Reply => {
|
|
27
|
+
if (seen.path === '/v1/sys/remount') return STARTED;
|
|
28
|
+
const reply = statuses[Math.min(polls, statuses.length - 1)] ?? FORGOTTEN;
|
|
29
|
+
polls += 1;
|
|
30
|
+
return reply;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FAST = { attempts: 3, intervalMillis: 1 };
|
|
35
|
+
const moved = (confirm = Effect.succeed(true)) => remountAndWait('kv', 'kv-moved', confirm, FAST);
|
|
36
|
+
|
|
37
|
+
describe('remountAndWait', () => {
|
|
38
|
+
it('posts from/to and polls until success', async () => {
|
|
39
|
+
await withFake(script(status('in-progress'), status('success')), async (bao) => {
|
|
40
|
+
await run({ BAO_ADDR: bao.address }, moved());
|
|
41
|
+
assert.deepEqual(
|
|
42
|
+
bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
|
|
43
|
+
[
|
|
44
|
+
['POST', '/v1/sys/remount', JSON.stringify({ from: 'kv', to: 'kv-moved' })],
|
|
45
|
+
['GET', '/v1/sys/remount/status/m-1', ''],
|
|
46
|
+
['GET', '/v1/sys/remount/status/m-1', ''],
|
|
47
|
+
],
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('fails a migration OpenBao reports failed, naming the id', async () => {
|
|
53
|
+
await withFake(script(status('failure')), async (bao) => {
|
|
54
|
+
await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), (error: unknown) => {
|
|
55
|
+
assert.ok(error instanceof RemountError);
|
|
56
|
+
assert.match(error.message, /migration m-1\) FAILED/);
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('settles a forgotten migration from the mount tables, both ways', async () => {
|
|
63
|
+
await withFake(script(FORGOTTEN), async (bao) => {
|
|
64
|
+
await run({ BAO_ADDR: bao.address }, moved(Effect.succeed(true)));
|
|
65
|
+
});
|
|
66
|
+
await withFake(script(FORGOTTEN), async (bao) => {
|
|
67
|
+
await assert.rejects(
|
|
68
|
+
run({ BAO_ADDR: bao.address }, moved(Effect.succeed(false))),
|
|
69
|
+
/no longer knows the migration/,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('gives up after the bounded number of status reads', async () => {
|
|
75
|
+
await withFake(script(status('in-progress')), async (bao) => {
|
|
76
|
+
await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), /still in progress after 3/);
|
|
77
|
+
assert.equal(bao.seen.length, 4);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('fails a start that names no migration id', async () => {
|
|
82
|
+
const answer = () => ({ json: { data: {} }, status: 200 });
|
|
83
|
+
await withFake(answer, async (bao) => {
|
|
84
|
+
await assert.rejects(run({ BAO_ADDR: bao.address }, moved()), /no migration_id/);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sys/remount` — move a secrets engine or auth method to a new path WITH its data — and the status
|
|
3
|
+
* polling it needs, because the move finishes after the call returns.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ FROM openbao v2.6.2, NOT RECALLED:
|
|
6
|
+
* · api/sys_mounts.go:95-191 — the CLI's `bao secrets move` POSTs `{from, to}` to sys/remount,
|
|
7
|
+
* takes `data.migration_id`, then GETs sys/remount/status/<id> once a second until
|
|
8
|
+
* `migration_info.status` is `success` or `failure`. This is that loop, bounded.
|
|
9
|
+
* · vault/logical_system.go:1257-1357 — the handler validates, records the migration and
|
|
10
|
+
* answers AT ONCE; a goroutine does the move. So a 200 means "queued", never "moved".
|
|
11
|
+
* · vault/mount.go:630-735 — the mount keeps its UUID, and storage lives under the UUID, so every
|
|
12
|
+
* secret stays. ⛔ BUT EVERY LEASE UNDER THE SOURCE IS REVOKED (`RevokePrefix`, :684): a move of
|
|
13
|
+
* a dynamic-secrets mount kills every credential it has issued. KV has no leases.
|
|
14
|
+
* · vault/logical_system.go:67-69 — `remount` is a sudo path. The status path is not.
|
|
15
|
+
* ⚠️ THE STATUS TRACKER IS IN MEMORY (vault/mount.go:2303-2336, a sync.Map on the active node). A
|
|
16
|
+
* restart or leader change mid-move loses it, and the status read then answers 404. That is not a
|
|
17
|
+
* failure and not a success; `confirm` settles it from the mount tables, or the call fails.
|
|
18
|
+
* ⛔ POLICIES ARE NOT REWRITTEN. Every ACL path naming the old mount keeps naming it.
|
|
19
|
+
*/
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
22
|
+
import { baoCall, baoRead } from './bao-http.ts';
|
|
23
|
+
import type { BaoError } from './bao-status.ts';
|
|
24
|
+
|
|
25
|
+
/** A move OpenBao reported failed, or one this could not confirm. Never carries a token. */
|
|
26
|
+
export class RemountError extends Error {
|
|
27
|
+
constructor(message: string) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = 'RemountError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface RemountOptions {
|
|
34
|
+
/** Milliseconds between status reads. Default 1000 — the CLI's own pace. */
|
|
35
|
+
readonly intervalMillis?: number;
|
|
36
|
+
/** Status reads before giving up. Default 120, so about two minutes. */
|
|
37
|
+
readonly attempts?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
41
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
42
|
+
? (value as Record<string, unknown>)
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
/** POST sys/remount and return the migration id. `from`/`to` are full paths (`auth/x` for auth). */
|
|
46
|
+
export const startRemount = (
|
|
47
|
+
from: string,
|
|
48
|
+
to: string,
|
|
49
|
+
): Effect.Effect<string, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
50
|
+
Effect.flatMap(baoCall('write', 'POST', 'sys/remount', { from, to }), (body) => {
|
|
51
|
+
const id = record(body?.['data'])?.['migration_id'];
|
|
52
|
+
return typeof id === 'string' && id !== ''
|
|
53
|
+
? Effect.succeed(id)
|
|
54
|
+
: Effect.fail(new RemountError(`sys/remount ${from} -> ${to} answered no migration_id.`));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** `in-progress`, `success`, `failure` — or undefined when the server no longer knows the id. */
|
|
58
|
+
export const remountStatus = (
|
|
59
|
+
id: string,
|
|
60
|
+
): Effect.Effect<string | undefined, BaoError, HttpClient.HttpClient> =>
|
|
61
|
+
Effect.map(baoRead(`sys/remount/status/${encodeURIComponent(id)}`), (data) => {
|
|
62
|
+
if (data === undefined) return undefined;
|
|
63
|
+
const status = record(data['migration_info'])?.['status'];
|
|
64
|
+
return typeof status === 'string' ? status : 'unknown';
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Move `from` to `to` and wait until OpenBao says it is done.
|
|
69
|
+
*
|
|
70
|
+
* @param confirm answers "does the mount table show the move finished?" — asked only when the
|
|
71
|
+
* status tracker has forgotten the id. True is success; false fails.
|
|
72
|
+
*/
|
|
73
|
+
export const remountAndWait = (
|
|
74
|
+
from: string,
|
|
75
|
+
to: string,
|
|
76
|
+
confirm: Effect.Effect<boolean, BaoError, HttpClient.HttpClient>,
|
|
77
|
+
options: RemountOptions = {},
|
|
78
|
+
): Effect.Effect<void, BaoError | RemountError, HttpClient.HttpClient> =>
|
|
79
|
+
Effect.gen(function* () {
|
|
80
|
+
const interval = options.intervalMillis ?? 1000;
|
|
81
|
+
const attempts = Math.max(1, options.attempts ?? 120);
|
|
82
|
+
const id = yield* startRemount(from, to);
|
|
83
|
+
const named = `sys/remount ${from} -> ${to} (migration ${id})`;
|
|
84
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
85
|
+
const status = yield* remountStatus(id);
|
|
86
|
+
if (status === 'success') return;
|
|
87
|
+
if (status === 'failure') {
|
|
88
|
+
return yield* Effect.fail(
|
|
89
|
+
new RemountError(
|
|
90
|
+
`${named} FAILED. The server log carries the reason, tagged with that migration_id. ` +
|
|
91
|
+
'Read sys/mounts (or sys/auth) before retrying: the source may be tainted.',
|
|
92
|
+
),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (status === undefined) {
|
|
96
|
+
if (yield* confirm) return;
|
|
97
|
+
return yield* Effect.fail(
|
|
98
|
+
new RemountError(
|
|
99
|
+
`${named}: the server no longer knows the migration (its tracker is in memory, lost ` +
|
|
100
|
+
'on a restart or leader change), and the mount table does not show the move done.',
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (attempt < attempts) yield* Effect.sleep(interval);
|
|
105
|
+
}
|
|
106
|
+
return yield* Effect.fail(
|
|
107
|
+
new RemountError(
|
|
108
|
+
`${named} is still in progress after ${String(attempts)} status reads. Do not re-run ` +
|
|
109
|
+
`until sys/remount/status/${id} settles.`,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared role loop against a fake, through Bao.JwtAuthConfig — the family where `wouldErase`
|
|
3
|
+
* matters most: a live OIDC client must never be written over, because the write would erase a
|
|
4
|
+
* secret the read never shows.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import { type Reply, type Seen, run, withFake } from './fake-bao.ts';
|
|
9
|
+
import { jwtConfigSpec } from './jwt-config.ts';
|
|
10
|
+
import { problems } from './jwt-config-form.ts';
|
|
11
|
+
import { planRole, reconcileRole } from './role-reconcile.ts';
|
|
12
|
+
|
|
13
|
+
const PROPS = {
|
|
14
|
+
boundIssuer: 'https://token.actions.example.com',
|
|
15
|
+
mount: 'jwt-github',
|
|
16
|
+
oidcDiscoveryUrl: 'https://token.actions.example.com',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const STORED = {
|
|
20
|
+
bound_issuer: 'https://token.actions.example.com',
|
|
21
|
+
default_role: '',
|
|
22
|
+
jwks_ca_pem: '',
|
|
23
|
+
jwks_url: '',
|
|
24
|
+
jwt_supported_algs: [],
|
|
25
|
+
jwt_validation_pubkeys: [],
|
|
26
|
+
namespace_in_state: true,
|
|
27
|
+
oidc_client_id: '',
|
|
28
|
+
oidc_discovery_ca_pem: '',
|
|
29
|
+
oidc_discovery_url: 'https://token.actions.example.com',
|
|
30
|
+
oidc_response_mode: '',
|
|
31
|
+
oidc_response_types: [],
|
|
32
|
+
override_allowed_server_names: null,
|
|
33
|
+
provider_config: {},
|
|
34
|
+
status: 'valid',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Reads answer from `reads` in order (the last repeats); writes answer 204. */
|
|
38
|
+
const script = (...reads: Reply[]) => {
|
|
39
|
+
let count = 0;
|
|
40
|
+
return (seen: Seen): Reply => {
|
|
41
|
+
if (seen.method !== 'GET') return { status: 204 };
|
|
42
|
+
const reply = reads[Math.min(count, reads.length - 1)] ?? { status: 500 };
|
|
43
|
+
count += 1;
|
|
44
|
+
return reply;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
const ABSENT: Reply = { json: { errors: [] }, status: 404 };
|
|
48
|
+
const found = (data: Record<string, unknown>): Reply => ({ json: { data }, status: 200 });
|
|
49
|
+
const writes = (seen: Seen[]) => seen.filter((each) => each.method !== 'GET');
|
|
50
|
+
|
|
51
|
+
describe('role loop (Bao.JwtAuthConfig)', () => {
|
|
52
|
+
it('writes an absent config once, as PUT, and returns what it read back', async () => {
|
|
53
|
+
await withFake(script(ABSENT, found(STORED)), async (bao) => {
|
|
54
|
+
const attributes = await run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS)));
|
|
55
|
+
assert.equal(attributes.mount, 'jwt-github');
|
|
56
|
+
const [write] = writes(bao.seen);
|
|
57
|
+
assert.equal(write?.method, 'PUT');
|
|
58
|
+
assert.equal(write?.path, '/v1/auth/jwt-github/config');
|
|
59
|
+
assert.ok(!(write?.body ?? '').includes('oidc_client_secret'));
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('writes nothing and plans noop when live already matches', async () => {
|
|
64
|
+
await withFake(script(found(STORED)), async (bao) => {
|
|
65
|
+
assert.equal(await run({ BAO_ADDR: bao.address }, planRole(jwtConfigSpec(PROPS))), 'noop');
|
|
66
|
+
await run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS)));
|
|
67
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('refuses to write over a live OIDC client, naming what would be erased', async () => {
|
|
72
|
+
const oidc = found({
|
|
73
|
+
...STORED,
|
|
74
|
+
bound_issuer: 'https://old.example.com',
|
|
75
|
+
oidc_client_id: 'client-a',
|
|
76
|
+
});
|
|
77
|
+
await withFake(script(oidc), async (bao) => {
|
|
78
|
+
await assert.rejects(
|
|
79
|
+
run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS))),
|
|
80
|
+
/oidc_client_id \(and its unreadable secret\)/,
|
|
81
|
+
);
|
|
82
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('refuses a write that reads back different', async () => {
|
|
87
|
+
const other = found({ ...STORED, bound_issuer: 'https://elsewhere.example.com' });
|
|
88
|
+
await withFake(script(ABSENT, other), async (bao) => {
|
|
89
|
+
await assert.rejects(
|
|
90
|
+
run({ BAO_ADDR: bao.address }, reconcileRole(jwtConfigSpec(PROPS))),
|
|
91
|
+
/read back different/,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('plans update, never noop, for a declaration it would refuse', async () => {
|
|
97
|
+
const bad = { ...PROPS, jwksUrl: 'https://keys.example.com' };
|
|
98
|
+
assert.match(problems(bad).join(), /exactly one/);
|
|
99
|
+
await withFake(script(found(STORED)), async (bao) => {
|
|
100
|
+
assert.equal(await run({ BAO_ADDR: bao.address }, planRole(jwtConfigSpec(bad))), 'update');
|
|
101
|
+
assert.equal(bao.seen.length, 0);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The read / plan / reconcile loop the newer role families share (Bao.JwtRole, Bao.KubernetesRole,
|
|
3
|
+
* Bao.JwtAuthConfig): one path, one body, one comparison. Each family supplies its own form; this
|
|
4
|
+
* owns the order of operations, which is where the estate's providers have gone wrong before.
|
|
5
|
+
*
|
|
6
|
+
* ★ THE ORDER, AND WHY EACH STEP IS THERE:
|
|
7
|
+
* 1. Refuse a declaration the family knows is wrong, before any call (`problems`).
|
|
8
|
+
* 2. Read live. Write only when it differs — an identical write is still an audit line.
|
|
9
|
+
* 3. Before writing over a live object, refuse if the write would silently destroy something
|
|
10
|
+
* this resource does not model (`wouldErase`, the ssh-role.ts pattern).
|
|
11
|
+
* 4. Re-read, and refuse unless live now MATCHES. A 2xx is not proof: the server can accept a
|
|
12
|
+
* field and store something else (pki-role.ts has the history).
|
|
13
|
+
* ⛔ A DECLARATION reconcile WOULD REFUSE NEVER PLANS `noop` — `planRole` routes it to `update`,
|
|
14
|
+
* so the refusal is seen at the next deploy instead of hiding behind a clean plan.
|
|
15
|
+
*/
|
|
16
|
+
import * as Effect from 'effect/Effect';
|
|
17
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
18
|
+
import { baoRead, baoWrite } from './bao-http.ts';
|
|
19
|
+
import type { BaoError } from './bao-status.ts';
|
|
20
|
+
|
|
21
|
+
export interface RoleSpec<A> {
|
|
22
|
+
/** `Bao.JwtRole` etc., for messages. */
|
|
23
|
+
readonly family: string;
|
|
24
|
+
/** The API path, e.g. `auth/jwt/role/humans`. */
|
|
25
|
+
readonly path: string;
|
|
26
|
+
readonly problems: readonly string[];
|
|
27
|
+
readonly attributesOf: (live: Record<string, unknown>) => A;
|
|
28
|
+
readonly matches: (attributes: A) => boolean;
|
|
29
|
+
readonly body: Readonly<Record<string, unknown>>;
|
|
30
|
+
/** Live fields a write would reset that the declaration does not model. */
|
|
31
|
+
readonly wouldErase?: (live: Record<string, unknown>) => readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Found<A> = { readonly attributes: A; readonly live: Record<string, unknown> };
|
|
35
|
+
|
|
36
|
+
export const readRoleAt = <A>(
|
|
37
|
+
spec: RoleSpec<A>,
|
|
38
|
+
): Effect.Effect<Found<A> | undefined, BaoError, HttpClient.HttpClient> =>
|
|
39
|
+
Effect.map(baoRead(spec.path), (live) =>
|
|
40
|
+
live === undefined ? undefined : { attributes: spec.attributesOf(live), live },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** `update` or `noop` — the part of a diff after the identity checks. */
|
|
44
|
+
export const planRole = <A>(
|
|
45
|
+
spec: RoleSpec<A>,
|
|
46
|
+
): Effect.Effect<'noop' | 'update', BaoError, HttpClient.HttpClient> =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
if (spec.problems.length > 0) return 'update';
|
|
49
|
+
const found = yield* readRoleAt(spec);
|
|
50
|
+
return found !== undefined && spec.matches(found.attributes) ? 'noop' : 'update';
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const refuse = (spec: { readonly family: string; readonly path: string }, message: string) =>
|
|
54
|
+
Effect.die(new Error(`${spec.family} ${spec.path}: ${message}`));
|
|
55
|
+
|
|
56
|
+
export const reconcileRole = <A>(
|
|
57
|
+
spec: RoleSpec<A>,
|
|
58
|
+
): Effect.Effect<A, BaoError, HttpClient.HttpClient> =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
if (spec.problems.length > 0) return yield* refuse(spec, spec.problems.join('; '));
|
|
61
|
+
const found = yield* readRoleAt(spec);
|
|
62
|
+
if (found === undefined || !spec.matches(found.attributes)) {
|
|
63
|
+
const lost = found === undefined ? [] : (spec.wouldErase?.(found.live) ?? []);
|
|
64
|
+
if (lost.length > 0) {
|
|
65
|
+
return yield* refuse(
|
|
66
|
+
spec,
|
|
67
|
+
`live carries ${lost.join(', ')}, which this resource does not declare and the write ` +
|
|
68
|
+
'would reset. Fold it into the declaration, or clear it by hand first.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
// ★ PUT, as `bao write` sends (api/logical.go); http/logical.go maps PUT and POST alike.
|
|
72
|
+
yield* baoWrite('PUT', spec.path, spec.body);
|
|
73
|
+
}
|
|
74
|
+
const after = yield* readRoleAt(spec);
|
|
75
|
+
if (after === undefined) return yield* refuse(spec, 'write reported success but it is absent.');
|
|
76
|
+
if (!spec.matches(after.attributes)) {
|
|
77
|
+
return yield* refuse(
|
|
78
|
+
spec,
|
|
79
|
+
'wrote cleanly but read back different — OpenBao stored something other than what was ' +
|
|
80
|
+
'declared. Read it by hand before retrying.',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return after.attributes;
|
|
84
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.SshRole's two shapes — a user-cert role and a host-cert role — through writeBody and back
|
|
3
|
+
* through attributesOf, the round trip every plan rests on.
|
|
4
|
+
*
|
|
5
|
+
* ★ `cert_type` IS NOT A ROLE FIELD. It is a SIGN parameter, default `user` (openbao v2.6.2
|
|
6
|
+
* builtin/logical/ssh/path_sign.go:50-54); a host-cert role is `allow_host_certificates` plus the
|
|
7
|
+
* domain fields, and the host asks for `cert_type=host` when it signs. path_roles.go has no such
|
|
8
|
+
* field, so nothing here sends one.
|
|
9
|
+
*/
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { describe, it } from 'node:test';
|
|
12
|
+
import {
|
|
13
|
+
type BaoSshRoleProps,
|
|
14
|
+
attributesOf,
|
|
15
|
+
matches,
|
|
16
|
+
resolve,
|
|
17
|
+
writeBody,
|
|
18
|
+
} from './ssh-role-form.ts';
|
|
19
|
+
|
|
20
|
+
const USER: BaoSshRoleProps = {
|
|
21
|
+
allowUserCertificates: true,
|
|
22
|
+
allowedUsers: ['ops', 'deploy'],
|
|
23
|
+
defaultExtensions: { 'permit-pty': '', 'permit-agent-forwarding': '' },
|
|
24
|
+
maxTtl: '16h',
|
|
25
|
+
name: 'operator',
|
|
26
|
+
ttl: '16h',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const HOST: BaoSshRoleProps = {
|
|
30
|
+
allowHostCertificates: true,
|
|
31
|
+
allowSubdomains: true,
|
|
32
|
+
allowedDomains: ['example.test', 'lab.example.test'],
|
|
33
|
+
allowedUsers: [],
|
|
34
|
+
maxTtl: '8760h',
|
|
35
|
+
mount: 'ssh-host/',
|
|
36
|
+
name: 'host',
|
|
37
|
+
ttl: '8760h',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('ssh role writeBody', () => {
|
|
41
|
+
it('sends the maps as JSON objects, never as JSON strings (sdk TypeMap refuses a string)', () => {
|
|
42
|
+
const body = writeBody(resolve(USER));
|
|
43
|
+
assert.deepEqual(body['default_extensions'], {
|
|
44
|
+
'permit-agent-forwarding': '',
|
|
45
|
+
'permit-pty': '',
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(body['default_critical_options'], {});
|
|
48
|
+
assert.equal(typeof body['default_extensions'], 'object');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('writes a user-cert role with key_type ca, sorted principals and a stated TTL', () => {
|
|
52
|
+
const body = writeBody(resolve(USER));
|
|
53
|
+
assert.equal(body['key_type'], 'ca');
|
|
54
|
+
assert.equal(body['allowed_users'], 'deploy,ops');
|
|
55
|
+
assert.equal(body['allow_user_certificates'], 'true');
|
|
56
|
+
assert.equal(body['allow_host_certificates'], 'false');
|
|
57
|
+
assert.equal(body['ttl'], '16h');
|
|
58
|
+
assert.equal('cert_type' in body, false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('writes a host-cert role with domains and subdomains on its own mount', () => {
|
|
62
|
+
const form = resolve(HOST);
|
|
63
|
+
const body = writeBody(form);
|
|
64
|
+
assert.equal(form.mount, 'ssh-host');
|
|
65
|
+
assert.equal(body['allow_host_certificates'], 'true');
|
|
66
|
+
assert.equal(body['allow_user_certificates'], 'false');
|
|
67
|
+
assert.equal(body['allowed_domains'], 'example.test,lab.example.test');
|
|
68
|
+
assert.equal(body['allow_subdomains'], 'true');
|
|
69
|
+
assert.equal(body['allowed_users'], '');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('ssh role round trip', () => {
|
|
74
|
+
it('matches the role OpenBao reads back — seconds, objects, comma strings', () => {
|
|
75
|
+
const live = {
|
|
76
|
+
allow_host_certificates: false,
|
|
77
|
+
allow_user_certificates: true,
|
|
78
|
+
allowed_users: 'ops,deploy',
|
|
79
|
+
default_critical_options: {},
|
|
80
|
+
default_extensions: { 'permit-agent-forwarding': '', 'permit-pty': '' },
|
|
81
|
+
key_type: 'ca',
|
|
82
|
+
max_ttl: 57600,
|
|
83
|
+
ttl: 57600,
|
|
84
|
+
};
|
|
85
|
+
const form = resolve(USER);
|
|
86
|
+
assert.equal(matches(attributesOf(form, live), form), true);
|
|
87
|
+
assert.equal(matches(attributesOf(form, { ...live, ttl: 14400 }), form), false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('matches a host role read back', () => {
|
|
91
|
+
const live = {
|
|
92
|
+
allow_host_certificates: true,
|
|
93
|
+
allow_subdomains: true,
|
|
94
|
+
allowed_domains: 'lab.example.test,example.test',
|
|
95
|
+
allowed_users: '',
|
|
96
|
+
default_critical_options: {},
|
|
97
|
+
default_extensions: {},
|
|
98
|
+
key_type: 'ca',
|
|
99
|
+
max_ttl: 31536000,
|
|
100
|
+
ttl: 31536000,
|
|
101
|
+
};
|
|
102
|
+
const form = resolve(HOST);
|
|
103
|
+
assert.equal(matches(attributesOf(form, live), form), true);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { sha256 } from './digest.ts';
|
|
15
15
|
import { parseDuration, ttlSeconds } from './mount-form.ts';
|
|
16
|
+
import { sshMountOf as mountOf } from './mount-path.ts';
|
|
16
17
|
|
|
17
18
|
export interface BaoSshRoleProps {
|
|
18
19
|
/** Role name, as `<mount>/roles/{name}` takes it. */
|
|
@@ -76,8 +77,7 @@ export interface BaoSshRoleAttributes extends Omit<BaoSshRoleForm, 'maxTtl' | 't
|
|
|
76
77
|
digest: string;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
export const mountOf = (mount: string | undefined) => (mount ?? 'ssh').replace(/\/+$/, '');
|
|
80
|
+
export { mountOf };
|
|
81
81
|
|
|
82
82
|
export const readPath = (mount: string | undefined, name: string) =>
|
|
83
83
|
`${mountOf(mount)}/roles/${name}`;
|
|
@@ -159,26 +159,28 @@ export const attributesOf = (
|
|
|
159
159
|
};
|
|
160
160
|
|
|
161
161
|
/**
|
|
162
|
-
* ⛔ THE MAPS CROSS THE WIRE AS A JSON STRING
|
|
163
|
-
* `bao write -output-curl-string`
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
162
|
+
* ⛔ THE MAPS CROSS THE WIRE AS JSON OBJECTS. A JSON STRING IS REJECTED, SO EVERY WRITE FAILED.
|
|
163
|
+
* History: `bao write -output-curl-string` showed the CLI sending `k=v` values as strings,
|
|
164
|
+
* so this once sent `"{\"permit-pty\":\"\"}"` on the reasoning that a `framework` TypeMap
|
|
165
|
+
* JSON-decodes a string. ⚠️ IT DOES NOT. MEASURED 2026-09-21 against sdk v2.6.2 itself (a Go
|
|
166
|
+
* probe of framework.FieldData.Validate, no server): TypeMap is `mapstructure.WeakDecode`
|
|
167
|
+
* (framework/field_data.go:271-276), which refuses a string with "expected type
|
|
168
|
+
* 'map[string]interface {}', got unconvertible type 'string'" — and framework/backend.go:
|
|
169
|
+
* 281-285 answers that with a 400 before the handler runs. `"{}"` fails the same way, so every
|
|
170
|
+
* Bao.SshRole write, not only those with extensions, would have been refused. It went unseen
|
|
171
|
+
* because the adopted roles already matched and no write was ever sent. The same probe
|
|
172
|
+
* accepted the object form and every other field of writeBody as sent.
|
|
172
173
|
*/
|
|
173
|
-
const mapValue = (map: Readonly<Record<string, string>>) =>
|
|
174
|
+
const mapValue = (map: Readonly<Record<string, string>>) => extMap(map);
|
|
174
175
|
|
|
175
176
|
/**
|
|
176
177
|
* ⛔ EVERY MANAGED FIELD IS SENT ON EVERY WRITE, INCLUDING THE FALSE ONES. The role write is
|
|
177
178
|
* a full replace (see ssh-role.ts), so an omitted field is not "left alone" — it is reset.
|
|
178
|
-
* Sending the resolved form makes the body and the declaration the same statement.
|
|
179
|
-
*
|
|
179
|
+
* Sending the resolved form makes the body and the declaration the same statement. Strings,
|
|
180
|
+
* exactly the `k=v` pairs `bao write` sent, for `PUT <mount>/roles/<name>` — except the two
|
|
181
|
+
* maps, which must be objects (the ⛔ on mapValue).
|
|
180
182
|
*/
|
|
181
|
-
export const writeBody = (form: BaoSshRoleForm): Record<string,
|
|
183
|
+
export const writeBody = (form: BaoSshRoleForm): Record<string, unknown> => ({
|
|
182
184
|
key_type: 'ca',
|
|
183
185
|
allowed_users: form.allowedUsers.join(','),
|
|
184
186
|
default_user: form.defaultUser,
|