@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
package/src/openbao/pki-role.ts
CHANGED
|
@@ -44,12 +44,13 @@ import * as Provider from 'alchemy/Provider';
|
|
|
44
44
|
import * as Effect from 'effect/Effect';
|
|
45
45
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
46
46
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
47
|
-
import { mountPath
|
|
47
|
+
import { mountPath } from './mount-form.ts';
|
|
48
48
|
import {
|
|
49
49
|
type BaoPkiRoleAttributes,
|
|
50
50
|
type BaoPkiRoleProps,
|
|
51
51
|
attributesOf,
|
|
52
52
|
matches,
|
|
53
|
+
problems,
|
|
53
54
|
rolePath,
|
|
54
55
|
writeBody,
|
|
55
56
|
} from './pki-role-form.ts';
|
|
@@ -75,12 +76,6 @@ const readRole = (props: BaoPkiRoleProps) =>
|
|
|
75
76
|
return attributesOf(props, live);
|
|
76
77
|
});
|
|
77
78
|
|
|
78
|
-
/** Duration props `bao write` must never see — reconcile turns these into a refusal. */
|
|
79
|
-
const badDurations = (props: BaoPkiRoleProps) =>
|
|
80
|
-
[['ttl', props.ttl] as const, ['maxTtl', props.maxTtl] as const].filter(
|
|
81
|
-
([, text]) => parseDuration(text) === undefined,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
79
|
export const BaoPkiRoleProvider = () =>
|
|
85
80
|
Provider.effect(
|
|
86
81
|
BaoPkiRole,
|
|
@@ -111,18 +106,20 @@ export const BaoPkiRoleProvider = () =>
|
|
|
111
106
|
* ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
|
|
112
107
|
* engine is a different object under a different CA, never an in-place edit —
|
|
113
108
|
* `replace`, so Alchemy creates the new one and retires the old.
|
|
109
|
+
* ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
|
|
110
|
+
* so a new `name` read nothing at the new path, planned `update`, wrote the new role,
|
|
111
|
+
* and left the old one issuing certificates under no state record at all.
|
|
114
112
|
*/
|
|
115
|
-
if (mountPath(news.mount ?? 'pki') !== output.mount)
|
|
113
|
+
if (mountPath(news.mount ?? 'pki') !== output.mount || news.name !== output.name)
|
|
116
114
|
return { action: 'replace' } as const;
|
|
117
115
|
/**
|
|
118
116
|
* ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
|
|
119
117
|
* allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
|
|
120
118
|
* live role already in that state — and a noop there hides the refusal behind a
|
|
121
119
|
* clean plan until the next deploy. Route it to reconcile, which says why.
|
|
120
|
+
* (The full list is `problems` in pki-role-form.ts.)
|
|
122
121
|
*/
|
|
123
|
-
if (
|
|
124
|
-
return { action: 'update' } as const;
|
|
125
|
-
}
|
|
122
|
+
if (problems(news).length > 0) return { action: 'update' } as const;
|
|
126
123
|
const live = yield* readRole(news);
|
|
127
124
|
if (live === undefined) return { action: 'update' } as const;
|
|
128
125
|
return matches(live, news)
|
|
@@ -136,24 +133,12 @@ export const BaoPkiRoleProvider = () =>
|
|
|
136
133
|
* ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
|
|
137
134
|
* false on every live role, so a role with no allowed domains can issue NOTHING —
|
|
138
135
|
* and an unresolved or mistyped prop reads exactly like an author who meant it.
|
|
139
|
-
* policy.ts refuses an empty fragment directory for the same reason.
|
|
136
|
+
* policy.ts refuses an empty fragment directory for the same reason. The other
|
|
137
|
+
* refusals — an unparseable duration, noStore with generateLease — are in `problems`.
|
|
140
138
|
*/
|
|
141
|
-
|
|
142
|
-
return yield* Effect.die(
|
|
143
|
-
new Error(
|
|
144
|
-
`Bao.PkiRole ${path}: allowedDomains is empty. With allow_any_name false ` +
|
|
145
|
-
'that role can issue no certificate at all.',
|
|
146
|
-
),
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
const bad = badDurations(news);
|
|
139
|
+
const bad = problems(news);
|
|
150
140
|
if (bad.length > 0) {
|
|
151
|
-
return yield* Effect.die(
|
|
152
|
-
new Error(
|
|
153
|
-
`Bao.PkiRole ${path}: ${bad.map(([k, v]) => `${k}=${v}`).join(', ')} ` +
|
|
154
|
-
'is not a duration OpenBao parses (expect 30m, 720h, 8760h or 0).',
|
|
155
|
-
),
|
|
156
|
-
);
|
|
141
|
+
return yield* Effect.die(new Error(`Bao.PkiRole ${path}: ${bad.join(' ')}`));
|
|
157
142
|
}
|
|
158
143
|
const live = yield* readRole(news);
|
|
159
144
|
if (live === undefined || !matches(live, news)) {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.Plugin's form: what a declaration may say, the body it registers with, and the equality a
|
|
3
|
+
* plan's `noop` rests on. The shapes are the v2.6.2 handler's (plugin-form.ts cites the lines).
|
|
4
|
+
*/
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { describe, it } from 'node:test';
|
|
7
|
+
import {
|
|
8
|
+
type BaoPluginProps,
|
|
9
|
+
attributesOf,
|
|
10
|
+
matches,
|
|
11
|
+
problems,
|
|
12
|
+
registerBody,
|
|
13
|
+
resolve,
|
|
14
|
+
versionedPath,
|
|
15
|
+
} from './plugin-form.ts';
|
|
16
|
+
|
|
17
|
+
const SUM = '0123456789abcdef'.repeat(4);
|
|
18
|
+
const UNVERSIONED: BaoPluginProps = {
|
|
19
|
+
command: 'openbao-plugin-secrets-cloudflare',
|
|
20
|
+
name: 'openbao-plugin-secrets-cloudflare',
|
|
21
|
+
sha256: SUM.toUpperCase(),
|
|
22
|
+
type: 'secret',
|
|
23
|
+
};
|
|
24
|
+
const PROPS: BaoPluginProps = { ...UNVERSIONED, version: 'v0.1.2' };
|
|
25
|
+
|
|
26
|
+
describe('plugin form', () => {
|
|
27
|
+
it('resolves the defaults the register call stores, and lower-cases the sum', () => {
|
|
28
|
+
const form = resolve(UNVERSIONED);
|
|
29
|
+
assert.equal(form.sha256, SUM);
|
|
30
|
+
assert.deepEqual(form.args, []);
|
|
31
|
+
assert.equal(form.version, '');
|
|
32
|
+
assert.deepEqual(problems(resolve(PROPS)), []);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('refuses what the server would reject or store differently', () => {
|
|
36
|
+
const bad = (patch: Partial<BaoPluginProps>) => problems(resolve({ ...PROPS, ...patch }));
|
|
37
|
+
for (const name of ['a/b', '..x', '', 'has space']) assert.equal(bad({ name }).length, 1);
|
|
38
|
+
assert.equal(bad({ sha256: 'abc' }).length, 1);
|
|
39
|
+
for (const command of ['plugin --flag', 'sub/plugin', '/abs/plugin', '../plugin', './plugin']) {
|
|
40
|
+
assert.equal(bad({ command }).length, 1, command);
|
|
41
|
+
}
|
|
42
|
+
// ⚠️ Accepted by the server but stored canonicalised, so they would never read back equal.
|
|
43
|
+
for (const version of ['1.2.0', 'v1.2', 'v01.2.0']) assert.equal(bad({ version }).length, 1);
|
|
44
|
+
assert.ok(bad({ version: 'v1.2.0+builtin' }).length > 0);
|
|
45
|
+
assert.deepEqual(bad({ version: 'v1.2.0-rc.1' }), []);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('reads and deletes one version, and registers at the unversioned path', () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
versionedPath('secret', 'x', 'v1.0.0+meta'),
|
|
51
|
+
'sys/plugins/catalog/secret/x?version=v1.0.0%2Bmeta',
|
|
52
|
+
);
|
|
53
|
+
assert.equal(versionedPath('auth', 'x', ''), 'sys/plugins/catalog/auth/x');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('registers with the RegisterPluginInput fields and never env or a numeric type', () => {
|
|
57
|
+
assert.deepEqual(registerBody(resolve(PROPS)), {
|
|
58
|
+
command: PROPS.command,
|
|
59
|
+
sha256: SUM,
|
|
60
|
+
version: 'v0.1.2',
|
|
61
|
+
});
|
|
62
|
+
const withArgs = registerBody(resolve({ ...UNVERSIONED, args: ['--b', '--a'] }));
|
|
63
|
+
assert.deepEqual(withArgs, { args: ['--b', '--a'], command: PROPS.command, sha256: SUM });
|
|
64
|
+
assert.equal('env' in withArgs || 'type' in withArgs, false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('matches the live entry by value, args in order, and never a builtin', () => {
|
|
68
|
+
const form = resolve({ ...PROPS, args: ['--a', '--b'] });
|
|
69
|
+
const live = {
|
|
70
|
+
args: ['--a', '--b'],
|
|
71
|
+
builtin: false,
|
|
72
|
+
command: PROPS.command,
|
|
73
|
+
declarative: false,
|
|
74
|
+
name: PROPS.name,
|
|
75
|
+
oci: false,
|
|
76
|
+
sha256: SUM,
|
|
77
|
+
version: 'v0.1.2',
|
|
78
|
+
};
|
|
79
|
+
assert.equal(matches(attributesOf(form, live), form), true);
|
|
80
|
+
assert.equal(matches(attributesOf(form, { ...live, args: ['--b', '--a'] }), form), false);
|
|
81
|
+
assert.equal(matches(attributesOf(form, { ...live, sha256: 'f'.repeat(64) }), form), false);
|
|
82
|
+
assert.equal(matches(attributesOf(form, { ...live, builtin: true }), form), false);
|
|
83
|
+
const bare = resolve(UNVERSIONED);
|
|
84
|
+
assert.deepEqual(attributesOf(bare, { ...live, args: null, version: '' }).args, []);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form half of Bao.Plugin: props, the paths, the register body, and the equality that decides
|
|
3
|
+
* whether a plan says `noop`. Split from plugin.ts the way mount-form.ts is.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE CATALOG API, READ FROM openbao v2.6.2 — the docs AND the server, because they disagree:
|
|
6
|
+
* · docs/api/system/plugins-catalog.mdx — `POST sys/plugins/catalog/:type/:name` registers with
|
|
7
|
+
* `sha256`, `command`, `args`, `env`, `version`, `oci`; `GET` and `DELETE` take `?version=`, which
|
|
8
|
+
* is REQUIRED to read a plugin registered with one. All three need `sudo`.
|
|
9
|
+
* · vault/logical_system.go:515-576 handlePluginCatalogRead — answers `name`, `args`, `command`
|
|
10
|
+
* (relative to plugin_directory), `sha256`, `builtin`, `version`, `oci`, `declarative`. ⚠️ The
|
|
11
|
+
* docs' sample shows sha256 as BASE64; the server writes `hex.EncodeToString` (:561). Hex wins.
|
|
12
|
+
* · vault/logical_system.go:441-513 handlePluginCatalogUpdate, and :615-628 getVersion — the
|
|
13
|
+
* version is canonicalised to `"v" + semver.String()` before it is stored.
|
|
14
|
+
*/
|
|
15
|
+
import { sha256 as digestOf } from './digest.ts';
|
|
16
|
+
|
|
17
|
+
export type BaoPluginType = 'auth' | 'database' | 'secret';
|
|
18
|
+
|
|
19
|
+
export interface BaoPluginProps {
|
|
20
|
+
/** Catalog name — what a mount's `type` names, e.g. `openbao-plugin-secrets-cloudflare`. */
|
|
21
|
+
name: string;
|
|
22
|
+
/** Catalog type. `secret` for a secrets engine, `auth` for an auth method. */
|
|
23
|
+
type: BaoPluginType;
|
|
24
|
+
/** Hex SHA-256 of the binary already sitting in plugin_directory. Case is normalised. */
|
|
25
|
+
sha256: string;
|
|
26
|
+
/** The binary's file name inside plugin_directory — a bare name, no path, no arguments. */
|
|
27
|
+
command: string;
|
|
28
|
+
/**
|
|
29
|
+
* Canonical semver with its `v`, e.g. `v0.1.2`. ⚠️ When the binary reports its own version,
|
|
30
|
+
* declare EXACTLY that one: any other is refused at write ("plugin version mismatch",
|
|
31
|
+
* plugin_catalog.go setInternal), and none at all is filed under the reported one — see the ⚠️
|
|
32
|
+
* on SELF_REPORTED in plugin-reconcile.ts.
|
|
33
|
+
*/
|
|
34
|
+
version?: string;
|
|
35
|
+
/** Arguments, in order. ⛔ Never a secret: they land in Alchemy state and in every catalog read. */
|
|
36
|
+
args?: readonly string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** ⛔ NO SECRET HERE — names, a hash, a file name, arguments and a digest. */
|
|
40
|
+
export interface BaoPluginAttributes {
|
|
41
|
+
name: string;
|
|
42
|
+
type: BaoPluginType;
|
|
43
|
+
/** The canonical version the catalog stored, `''` when unversioned. Delete needs it. */
|
|
44
|
+
version: string;
|
|
45
|
+
sha256: string;
|
|
46
|
+
command: string;
|
|
47
|
+
args: readonly string[];
|
|
48
|
+
/** True when the catalog answered with OpenBao's own builtin rather than a registration. */
|
|
49
|
+
builtin: boolean;
|
|
50
|
+
/** SHA-256 of the managed fields — safe to persist; see policy.ts. */
|
|
51
|
+
digest: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every prop resolved to what the register call will store. */
|
|
55
|
+
export type BaoPluginForm = Required<BaoPluginProps>;
|
|
56
|
+
|
|
57
|
+
export const resolve = (props: BaoPluginProps): BaoPluginForm => ({
|
|
58
|
+
args: props.args ?? [],
|
|
59
|
+
command: props.command,
|
|
60
|
+
name: props.name,
|
|
61
|
+
sha256: props.sha256.toLowerCase(),
|
|
62
|
+
type: props.type,
|
|
63
|
+
version: props.version ?? '',
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ⛔ THE VERSION MUST ALREADY BE CANONICAL, OR THE PLAN NEVER GOES GREEN. The server stores
|
|
68
|
+
* `"v" + semver.String()` (getVersion, :615-628), so `1.2.0`, `v1.2` and `v01.2.0` are all
|
|
69
|
+
* written as `v1.2.0`, read back as `v1.2.0`, and compared unequal to the declaration forever.
|
|
70
|
+
* Refusing anything but the canonical spelling is cheaper than reimplementing go-version.
|
|
71
|
+
*/
|
|
72
|
+
const CANONICAL_VERSION =
|
|
73
|
+
/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/;
|
|
74
|
+
|
|
75
|
+
/** Everything wrong with the declaration, or `[]`. Checked before any request. */
|
|
76
|
+
export const problems = (form: BaoPluginForm): readonly string[] => {
|
|
77
|
+
const found: string[] = [];
|
|
78
|
+
// ⚠️ The route is `(?P<name>.+)` (logical_system_paths.go:1697), so a `/` would nest the storage
|
|
79
|
+
// key; the server refuses `..` in a name (plugin_catalog.go Set).
|
|
80
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(form.name) || form.name.includes('..')) {
|
|
81
|
+
found.push(`name ${JSON.stringify(form.name)} is not a plain catalog name`);
|
|
82
|
+
}
|
|
83
|
+
if (!/^[0-9a-f]{64}$/.test(form.sha256)) found.push('sha256 is not 64 hex characters');
|
|
84
|
+
/**
|
|
85
|
+
* ⛔ A BARE FILE NAME. The server splits `command` on spaces and keeps the rest as args
|
|
86
|
+
* (handlePluginCatalogUpdate :487-495), and refuses a binary whose directory is not
|
|
87
|
+
* plugin_directory itself (plugin_catalog.go setInternal, `symAbs != c.directory`) — so a
|
|
88
|
+
* path or an argument here either fails the write or reads back as something else.
|
|
89
|
+
*/
|
|
90
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(form.command) || form.command.includes('..')) {
|
|
91
|
+
found.push(`command ${JSON.stringify(form.command)} is not a bare file name`);
|
|
92
|
+
}
|
|
93
|
+
if (form.version !== '' && !CANONICAL_VERSION.test(form.version)) {
|
|
94
|
+
found.push(`version ${JSON.stringify(form.version)} is not canonical semver like v1.2.0`);
|
|
95
|
+
}
|
|
96
|
+
// ⚠️ `+builtin` metadata is reserved; the server refuses it (handlePluginCatalogUpdate :462-464).
|
|
97
|
+
if (/\+builtin/.test(form.version)) found.push('version metadata `builtin` is reserved');
|
|
98
|
+
return found;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** `?version=` only when there is one — an unversioned plugin is read without it. */
|
|
102
|
+
const versionQuery = (version: string) =>
|
|
103
|
+
version === '' ? '' : `?version=${encodeURIComponent(version)}`;
|
|
104
|
+
|
|
105
|
+
/** `sys/plugins/catalog/<type>/<name>` — register addresses the plugin here. */
|
|
106
|
+
export const catalogPath = (type: BaoPluginType, name: string): string =>
|
|
107
|
+
`sys/plugins/catalog/${type}/${name}`;
|
|
108
|
+
|
|
109
|
+
/** Read and delete address one VERSION of the plugin. */
|
|
110
|
+
export const versionedPath = (type: BaoPluginType, name: string, version: string): string =>
|
|
111
|
+
`${catalogPath(type, name)}${versionQuery(version)}`;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The register body — the fields RegisterPluginInput sends (api v2.6.0 sys_plugins.go:186-204),
|
|
115
|
+
* which is what `bao plugin register` sends.
|
|
116
|
+
*
|
|
117
|
+
* ★ `type` IS LEFT OUT OF THE BODY ON PURPOSE. The Go client puts it there as a NUMBER (PluginType
|
|
118
|
+
* has no MarshalJSON), and the route's `type` capture is what the handler reads. The path says it.
|
|
119
|
+
* ⛔ NO `env`, EVER. The catalog read never returns env (handlePluginCatalogRead's data map has no
|
|
120
|
+
* such key), so a declared env could not be diffed, and what env exists to carry into a plugin
|
|
121
|
+
* process is exactly what must not sit in Alchemy's unencrypted state. ⚠️ The register call is a
|
|
122
|
+
* FULL REPLACE (setInternal builds a fresh entry), so an env someone set by hand is dropped by
|
|
123
|
+
* the next write — which only happens when a managed field has drifted.
|
|
124
|
+
*/
|
|
125
|
+
export const registerBody = (form: BaoPluginForm): Record<string, unknown> => ({
|
|
126
|
+
command: form.command,
|
|
127
|
+
sha256: form.sha256,
|
|
128
|
+
...(form.args.length > 0 ? { args: form.args } : {}),
|
|
129
|
+
...(form.version === '' ? {} : { version: form.version }),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
133
|
+
|
|
134
|
+
/** ⚠️ `args` is a Go slice, so an entry stored without any can read back `null` — the same as `[]`. */
|
|
135
|
+
const list = (value: unknown): readonly string[] =>
|
|
136
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
137
|
+
|
|
138
|
+
export const attributesOf = (
|
|
139
|
+
form: BaoPluginForm,
|
|
140
|
+
live: Record<string, unknown>,
|
|
141
|
+
): BaoPluginAttributes => {
|
|
142
|
+
const attrs = {
|
|
143
|
+
name: form.name,
|
|
144
|
+
type: form.type,
|
|
145
|
+
version: text(live['version']),
|
|
146
|
+
sha256: text(live['sha256']).toLowerCase(),
|
|
147
|
+
command: text(live['command']),
|
|
148
|
+
args: list(live['args']),
|
|
149
|
+
builtin: live['builtin'] === true,
|
|
150
|
+
};
|
|
151
|
+
return { ...attrs, digest: digestOf(JSON.stringify(attrs)) };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** ⚠️ ORDER MATTERS for args — they are an argv, not a set. */
|
|
155
|
+
const sameArgs = (want: readonly string[], have: readonly string[]) =>
|
|
156
|
+
want.length === have.length && want.every((entry, index) => entry === have[index]);
|
|
157
|
+
|
|
158
|
+
/** True when the catalog already holds exactly this registration. A builtin never matches. */
|
|
159
|
+
export const matches = (attributes: BaoPluginAttributes, form: BaoPluginForm): boolean =>
|
|
160
|
+
!attributes.builtin &&
|
|
161
|
+
attributes.version === form.version &&
|
|
162
|
+
attributes.sha256 === form.sha256 &&
|
|
163
|
+
attributes.command === form.command &&
|
|
164
|
+
sameArgs(form.args, attributes.args);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.Plugin's read and reconcile, as plain effects — split from plugin.ts so the refusals below can
|
|
3
|
+
* be run against a fake server rather than only reasoned about.
|
|
4
|
+
*/
|
|
5
|
+
import * as Effect from 'effect/Effect';
|
|
6
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
7
|
+
import type { BaoError } from './bao-status.ts';
|
|
8
|
+
import {
|
|
9
|
+
type BaoPluginAttributes,
|
|
10
|
+
type BaoPluginForm,
|
|
11
|
+
type BaoPluginProps,
|
|
12
|
+
attributesOf,
|
|
13
|
+
matches,
|
|
14
|
+
problems,
|
|
15
|
+
resolve,
|
|
16
|
+
versionedPath,
|
|
17
|
+
} from './plugin-form.ts';
|
|
18
|
+
import { readPluginData, registerPlugin } from './plugin-wire.ts';
|
|
19
|
+
|
|
20
|
+
type Entry = { readonly attributes: BaoPluginAttributes; readonly live: Record<string, unknown> };
|
|
21
|
+
|
|
22
|
+
/** Live entry, plus the raw body — reconcile needs the raw half for `oci` and `declarative`. */
|
|
23
|
+
export const readEntry = (
|
|
24
|
+
form: BaoPluginForm,
|
|
25
|
+
): Effect.Effect<Entry | undefined, BaoError, HttpClient.HttpClient> =>
|
|
26
|
+
Effect.map(readPluginData(form), (live) =>
|
|
27
|
+
live === undefined ? undefined : { attributes: attributesOf(form, live), live },
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const refuse = (form: BaoPluginForm, message: string) =>
|
|
31
|
+
Effect.die(
|
|
32
|
+
new Error(`Bao.Plugin ${versionedPath(form.type, form.name, form.version)}: ${message}`),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* ⚠️ AN UNVERSIONED REGISTRATION OF A BINARY THAT REPORTS ITS OWN VERSION IS FILED UNDER THAT
|
|
37
|
+
* VERSION. setInternal (plugin_catalog.go:1222-1228) takes the self-reported version when the
|
|
38
|
+
* request names none, and the storage key carries it — so the unversioned read that follows finds
|
|
39
|
+
* nothing, or finds an OLDER genuinely-unversioned entry. The Cloudflare engine reports one
|
|
40
|
+
* (`framework.Backend.RunningVersion`). REASONED FROM SOURCE, not measured against a server.
|
|
41
|
+
* ⚠️ THE WRITE HAS ALREADY LANDED when this fires, so the catalog holds a registration no state
|
|
42
|
+
* record names. Declaring the reported version adopts it on the next deploy (same sha256 and
|
|
43
|
+
* command, so reconcile matches and writes nothing); declaring a DIFFERENT version is refused by
|
|
44
|
+
* the server itself (setInternal :1219-1221, "plugin version mismatch").
|
|
45
|
+
*/
|
|
46
|
+
const SELF_REPORTED =
|
|
47
|
+
'the write succeeded but the read-back does not match. If the binary reports its own version, ' +
|
|
48
|
+
'OpenBao filed the registration under it (plugin_catalog.go setInternal) — declare `version` ' +
|
|
49
|
+
'as exactly the version the binary reports; that adopts the entry this write just made.';
|
|
50
|
+
|
|
51
|
+
/** Register when the live entry differs, then prove it by reading back. Dies on a refusal. */
|
|
52
|
+
export const reconcilePlugin = (
|
|
53
|
+
props: BaoPluginProps,
|
|
54
|
+
): Effect.Effect<BaoPluginAttributes, BaoError, HttpClient.HttpClient> =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const form = resolve(props);
|
|
57
|
+
const bad = problems(form);
|
|
58
|
+
if (bad.length > 0) return yield* refuse(form, bad.join('; '));
|
|
59
|
+
|
|
60
|
+
const found = yield* readEntry(form);
|
|
61
|
+
if (found !== undefined && !matches(found.attributes, form)) {
|
|
62
|
+
/**
|
|
63
|
+
* ⛔ A DECLARATIVE OR OCI ENTRY BELONGS TO THE SERVER'S CONFIG FILE (`plugin` stanzas with
|
|
64
|
+
* plugin_auto_register), which re-registers it on every plugin reload. An API write turns
|
|
65
|
+
* it non-declarative, and the next reload then REFUSES the config entry: "conflicts with
|
|
66
|
+
* existing non-declarative plugin" (plugin_catalog.go:333-336).
|
|
67
|
+
*/
|
|
68
|
+
if (found.live['declarative'] === true || found.live['oci'] === true) {
|
|
69
|
+
return yield* refuse(
|
|
70
|
+
form,
|
|
71
|
+
'the live entry is declarative/OCI; manage it in server config.',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* ⛔ AN UNVERSIONED WRITE OVER A BUILTIN SHADOWS IT FOR EVERY MOUNT OF THAT TYPE. The catalog
|
|
76
|
+
* answered with OpenBao's own builtin (`builtin: true`), and get() returns an external entry
|
|
77
|
+
* before it ever looks at builtins (plugin_catalog.go:982-1036). Register a versioned plugin
|
|
78
|
+
* instead, which leaves the builtin where it is.
|
|
79
|
+
*/
|
|
80
|
+
if (found.attributes.builtin) {
|
|
81
|
+
return yield* refuse(form, 'this name is a builtin; an unversioned write would shadow it.');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (found === undefined || !matches(found.attributes, form)) yield* registerPlugin(form);
|
|
85
|
+
|
|
86
|
+
// ⚠️ RE-READ AND COMPARE, never echo the declaration — see SELF_REPORTED above.
|
|
87
|
+
const after = yield* readEntry(form);
|
|
88
|
+
if (after === undefined || !matches(after.attributes, form)) {
|
|
89
|
+
return yield* refuse(form, SELF_REPORTED);
|
|
90
|
+
}
|
|
91
|
+
return after.attributes;
|
|
92
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bao.Plugin's calls and reconcile against a fake. The parts worth pinning are the ones the source
|
|
3
|
+
* read turned up: a missing entry is a real 404, a builtin answers at an unversioned name, a
|
|
4
|
+
* declarative entry belongs to config, and a self-versioning binary is filed under its own version.
|
|
5
|
+
*/
|
|
6
|
+
import assert from 'node:assert/strict';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import { BaoError } from './bao-status.ts';
|
|
9
|
+
import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
|
|
10
|
+
import { type BaoPluginProps, resolve } from './plugin-form.ts';
|
|
11
|
+
import { reconcilePlugin } from './plugin-reconcile.ts';
|
|
12
|
+
import { deregisterPlugin, readPluginData, registerPlugin } from './plugin-wire.ts';
|
|
13
|
+
|
|
14
|
+
const SUM = '0123456789abcdef'.repeat(4);
|
|
15
|
+
const UNVERSIONED: BaoPluginProps = {
|
|
16
|
+
command: 'openbao-plugin-secrets-cloudflare',
|
|
17
|
+
name: 'openbao-plugin-secrets-cloudflare',
|
|
18
|
+
sha256: SUM,
|
|
19
|
+
type: 'secret',
|
|
20
|
+
};
|
|
21
|
+
const PROPS: BaoPluginProps = { ...UNVERSIONED, version: 'v0.1.2' };
|
|
22
|
+
const FORM = resolve(PROPS);
|
|
23
|
+
const PATH = '/v1/sys/plugins/catalog/secret/openbao-plugin-secrets-cloudflare';
|
|
24
|
+
const ENTRY = {
|
|
25
|
+
args: [],
|
|
26
|
+
builtin: false,
|
|
27
|
+
command: PROPS.command,
|
|
28
|
+
declarative: false,
|
|
29
|
+
name: PROPS.name,
|
|
30
|
+
oci: false,
|
|
31
|
+
sha256: SUM,
|
|
32
|
+
version: 'v0.1.2',
|
|
33
|
+
};
|
|
34
|
+
const ABSENT = { json: { errors: [] }, status: 404 };
|
|
35
|
+
const found = (data: Record<string, unknown>) => ({ json: { data }, status: 200 });
|
|
36
|
+
|
|
37
|
+
/** A catalog that answers reads with `before` until a PUT lands, and with `after` from then on. */
|
|
38
|
+
const catalog = (before: Reply, after: Reply = found(ENTRY)) => {
|
|
39
|
+
let registered = false;
|
|
40
|
+
return (seen: Seen) => {
|
|
41
|
+
if (seen.method === 'PUT') {
|
|
42
|
+
registered = true;
|
|
43
|
+
return { status: 204 };
|
|
44
|
+
}
|
|
45
|
+
return registered ? after : before;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const writes = (seen: Seen[]) => seen.filter((entry) => entry.method !== 'GET');
|
|
50
|
+
|
|
51
|
+
describe('plugin wire', () => {
|
|
52
|
+
it('reads a missing entry as absent, and a refused read as an error', async () => {
|
|
53
|
+
await withFake(
|
|
54
|
+
() => ABSENT,
|
|
55
|
+
async (bao) => {
|
|
56
|
+
assert.equal(await run({ BAO_ADDR: bao.address }, readPluginData(FORM)), undefined);
|
|
57
|
+
assert.equal(bao.seen[0]?.path, `${PATH}?version=v0.1.2`);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
await withFake(
|
|
61
|
+
() => ({ json: { errors: ['permission denied'] }, status: 403 }),
|
|
62
|
+
async (bao) => {
|
|
63
|
+
const error = await runFailure({ BAO_ADDR: bao.address }, readPluginData(FORM));
|
|
64
|
+
assert.ok(error instanceof BaoError && error.status === 403);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('registers and deregisters at the paths `bao plugin` uses', async () => {
|
|
70
|
+
await withFake(
|
|
71
|
+
() => ({ status: 204 }),
|
|
72
|
+
async (bao) => {
|
|
73
|
+
const env = { BAO_ADDR: bao.address };
|
|
74
|
+
await run(env, registerPlugin(FORM));
|
|
75
|
+
await run(env, deregisterPlugin('secret', PROPS.name, 'v0.1.2'));
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
bao.seen.map((seen) => [seen.method, seen.path, seen.body]),
|
|
78
|
+
[
|
|
79
|
+
[
|
|
80
|
+
'PUT',
|
|
81
|
+
PATH,
|
|
82
|
+
JSON.stringify({ command: PROPS.command, sha256: SUM, version: 'v0.1.2' }),
|
|
83
|
+
],
|
|
84
|
+
['DELETE', `${PATH}?version=v0.1.2`, ''],
|
|
85
|
+
],
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('reconcilePlugin', () => {
|
|
93
|
+
it('registers a missing plugin and returns what it read back', async () => {
|
|
94
|
+
await withFake(catalog(ABSENT), async (bao) => {
|
|
95
|
+
const attributes = await run({ BAO_ADDR: bao.address }, reconcilePlugin(PROPS));
|
|
96
|
+
assert.equal(attributes.version, 'v0.1.2');
|
|
97
|
+
assert.equal(writes(bao.seen).length, 1);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('writes nothing when the live entry already matches', async () => {
|
|
102
|
+
await withFake(catalog(found(ENTRY)), async (bao) => {
|
|
103
|
+
await run({ BAO_ADDR: bao.address }, reconcilePlugin(PROPS));
|
|
104
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('refuses to overwrite a declarative entry or shadow a builtin', async () => {
|
|
109
|
+
const cases = [
|
|
110
|
+
{ live: found({ ...ENTRY, declarative: true, sha256: 'f'.repeat(64) }), props: PROPS },
|
|
111
|
+
{
|
|
112
|
+
live: found({ builtin: true, command: '', name: 'kv', sha256: '', version: 'v2.6.2' }),
|
|
113
|
+
props: { ...UNVERSIONED, name: 'kv' },
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
for (const [index, { live, props }] of cases.entries()) {
|
|
117
|
+
await withFake(catalog(live), async (bao) => {
|
|
118
|
+
await assert.rejects(
|
|
119
|
+
run({ BAO_ADDR: bao.address }, reconcilePlugin(props)),
|
|
120
|
+
index === 0 ? /declarative/ : /builtin/,
|
|
121
|
+
);
|
|
122
|
+
assert.equal(writes(bao.seen).length, 0);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('names the self-reported version when an unversioned write reads back as nothing', async () => {
|
|
128
|
+
await withFake(catalog(ABSENT, ABSENT), async (bao) => {
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
run({ BAO_ADDR: bao.address }, reconcilePlugin(UNVERSIONED)),
|
|
131
|
+
/declare `version`/,
|
|
132
|
+
);
|
|
133
|
+
assert.equal(writes(bao.seen).length, 1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('refuses a bad declaration before sending anything', async () => {
|
|
138
|
+
await withFake(catalog(ABSENT), async (bao) => {
|
|
139
|
+
await assert.rejects(
|
|
140
|
+
run({ BAO_ADDR: bao.address }, reconcilePlugin({ ...PROPS, version: '0.1.2' })),
|
|
141
|
+
/canonical semver/,
|
|
142
|
+
);
|
|
143
|
+
assert.equal(bao.seen.length, 0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenBao calls Bao.Plugin makes — read one catalog entry, register, deregister. Split from
|
|
3
|
+
* plugin.ts so the calls can be pinned against a fake server, the way mount-wire.ts is.
|
|
4
|
+
*
|
|
5
|
+
* ★ ENDPOINTS, READ FROM openbao v2.6.2 api/sys_plugins.go (the client `bao plugin` uses) and the
|
|
6
|
+
* handlers in vault/logical_system.go:
|
|
7
|
+
* read GET sys/plugins/catalog/<type>/<name>[?version=] (GetPlugin, :159-183)
|
|
8
|
+
* register PUT sys/plugins/catalog/<type>/<name> (RegisterPlugin, :212-228)
|
|
9
|
+
* deregister DELETE sys/plugins/catalog/<type>/<name>[?version=] (DeregisterPlugin, :249-261)
|
|
10
|
+
* (api/sys_plugins.go diffed identical between v2.6.0 and v2.6.2.)
|
|
11
|
+
* ⚠️ ALL THREE NEED `sudo` ON THE PATH, not just the verb (docs/api/system/plugins-catalog.mdx). A
|
|
12
|
+
* token without it reads as 403, which is an error here, never "absent".
|
|
13
|
+
*/
|
|
14
|
+
import type * as Effect from 'effect/Effect';
|
|
15
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
16
|
+
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
17
|
+
import type { BaoError } from './bao-status.ts';
|
|
18
|
+
import {
|
|
19
|
+
type BaoPluginForm,
|
|
20
|
+
type BaoPluginType,
|
|
21
|
+
catalogPath,
|
|
22
|
+
registerBody,
|
|
23
|
+
versionedPath,
|
|
24
|
+
} from './plugin-form.ts';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One catalog entry, or undefined when the catalog has none at that name, type and version.
|
|
28
|
+
*
|
|
29
|
+
* ★ UNLIKE A MOUNT, A MISSING PLUGIN REALLY IS A 404. handlePluginCatalogRead returns `nil, nil` when
|
|
30
|
+
* the catalog lookup finds nothing (vault/logical_system.go:545-547), and RespondErrorCommon turns
|
|
31
|
+
* a nil READ response into 404 (sdk v2.6.2 logical/response_util.go:23-26). No second question is
|
|
32
|
+
* needed, unlike the mount-table check in mount-wire.ts.
|
|
33
|
+
* ⚠️ A BUILTIN ANSWERS TOO. With no external registration at an unversioned name, the catalog falls
|
|
34
|
+
* through to OpenBao's own builtin of that name and type (plugin_catalog.go get, :1016-1036), which
|
|
35
|
+
* reads back with `builtin: true` — present, but not a registration. plugin.ts decides what that
|
|
36
|
+
* means.
|
|
37
|
+
*/
|
|
38
|
+
export const readPluginData = (
|
|
39
|
+
form: BaoPluginForm,
|
|
40
|
+
): Effect.Effect<Record<string, unknown> | undefined, BaoError, HttpClient.HttpClient> =>
|
|
41
|
+
baoRead(versionedPath(form.type, form.name, form.version));
|
|
42
|
+
|
|
43
|
+
export const registerPlugin = (
|
|
44
|
+
form: BaoPluginForm,
|
|
45
|
+
): Effect.Effect<void, BaoError, HttpClient.HttpClient> =>
|
|
46
|
+
baoWrite('PUT', catalogPath(form.type, form.name), registerBody(form));
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* ★ IDEMPOTENT ON THE SERVER. deleteInternal deletes the storage key whether or not it exists
|
|
50
|
+
* (plugin_catalog.go:1272-1284) and the handler answers with no body, so a retried delete succeeds.
|
|
51
|
+
* ⚠️ BUT A MISSING TYPED KEY FALLS BACK TO THE BARE NAME. When `<type>/<name>[/<version>]` is not
|
|
52
|
+
* in storage, the same function deletes the key `<name>` instead — where registrations made
|
|
53
|
+
* before plugin types existed live, whatever their type. A fresh 2.x vault has none; one
|
|
54
|
+
* migrated from an old Vault may. REASONED FROM SOURCE, not run.
|
|
55
|
+
* ⛔ AND THAT IS THE DANGER: NOTHING CHECKS WHETHER A MOUNT STILL USES THE PLUGIN. The same function
|
|
56
|
+
* has no mount lookup at all, so deregistering the plugin a live mount runs succeeds. The running
|
|
57
|
+
* process is not killed by it; the mount breaks the next time OpenBao has to START the plugin — a
|
|
58
|
+
* reload, a restart, an unseal — long after the plan that did it. REASONED FROM SOURCE, not run.
|
|
59
|
+
*/
|
|
60
|
+
export const deregisterPlugin = (
|
|
61
|
+
type: BaoPluginType,
|
|
62
|
+
name: string,
|
|
63
|
+
version: string,
|
|
64
|
+
): Effect.Effect<void, BaoError, HttpClient.HttpClient> =>
|
|
65
|
+
baoDelete(versionedPath(type, name, version));
|