@homeflare/alchemy 0.7.0 → 0.10.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 +45 -45
- package/dist/caddy/config-lifecycle.d.ts +49 -3
- package/dist/caddy/config-lifecycle.d.ts.map +1 -1
- package/dist/caddy/config.d.ts +6 -2
- package/dist/caddy/config.d.ts.map +1 -1
- package/dist/caddy/fake-caddy.d.ts +11 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -1
- package/dist/caddy/index.d.ts +2 -1
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +270 -44
- package/dist/caddy/index.js.map +15 -11
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +1 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +51 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +1 -1
- package/dist/launchd/host-file-form.d.ts +5 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +14 -1
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts +2 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +2 -0
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +642 -49
- package/dist/launchd/index.js.map +24 -14
- package/dist/launchd/job-lifecycle.d.ts +8 -1
- package/dist/launchd/job-lifecycle.d.ts.map +1 -1
- package/dist/launchd/job-preflight.d.ts +13 -1
- package/dist/launchd/job-preflight.d.ts.map +1 -1
- package/dist/launchd/job-validate.d.ts +2 -0
- package/dist/launchd/job-validate.d.ts.map +1 -1
- package/dist/launchd/job.d.ts +2 -1
- package/dist/launchd/job.d.ts.map +1 -1
- package/dist/launchd/local-runner.d.ts +2 -0
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/providers.d.ts +2 -1
- package/dist/launchd/providers.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +14 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-acl.d.ts +25 -0
- package/dist/launchd/sudo-acl.d.ts.map +1 -0
- package/dist/launchd/sudo-allowlist.d.ts +47 -0
- package/dist/launchd/sudo-allowlist.d.ts.map +1 -0
- package/dist/launchd/sudo-guard.d.ts +83 -0
- package/dist/launchd/sudo-guard.d.ts.map +1 -0
- package/dist/launchd/sudo-runner.d.ts +60 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -0
- package/dist/launchd/sudo-said.d.ts +10 -0
- package/dist/launchd/sudo-said.d.ts.map +1 -0
- package/dist/launchd/sudo-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- package/dist/openbao/auth-method-reconcile.d.ts +6 -2
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -1
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +0 -9
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts +3 -2
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-bao.d.ts +18 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -1
- package/dist/openbao/fake-core.d.ts +39 -0
- package/dist/openbao/fake-core.d.ts.map +1 -0
- package/dist/openbao/fake-engines-roles.d.ts +32 -0
- package/dist/openbao/fake-engines-roles.d.ts.map +1 -0
- package/dist/openbao/fake-engines.d.ts +8 -2
- package/dist/openbao/fake-engines.d.ts.map +1 -1
- package/dist/openbao/fake-families.d.ts +52 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/fake-mounts.d.ts +16 -0
- package/dist/openbao/fake-mounts.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +5 -1
- package/dist/openbao/fake-stack.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +709 -359
- package/dist/openbao/index.js.map +32 -25
- package/dist/openbao/jwt-config.d.ts +5 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -1
- package/dist/openbao/jwt-role.d.ts +2 -1
- package/dist/openbao/jwt-role.d.ts.map +1 -1
- package/dist/openbao/kubernetes-role.d.ts +2 -1
- package/dist/openbao/kubernetes-role.d.ts.map +1 -1
- package/dist/openbao/mfa-enforcement.d.ts +12 -5
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts +6 -2
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/mount-reconcile.d.ts +5 -1
- package/dist/openbao/mount-reconcile.d.ts.map +1 -1
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-reconcile.d.ts +6 -2
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -1
- package/dist/openbao/plugin.d.ts.map +1 -1
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename-identity.d.ts +69 -0
- package/dist/openbao/rename-identity.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +13 -12
- package/dist/openbao/rename.d.ts.map +1 -1
- package/dist/openbao/role-reconcile.d.ts +11 -1
- package/dist/openbao/role-reconcile.d.ts.map +1 -1
- package/dist/openbao/ssh-role-erase.d.ts +11 -0
- package/dist/openbao/ssh-role-erase.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/dist/ownership/adopt.d.ts +52 -0
- package/dist/ownership/adopt.d.ts.map +1 -0
- package/dist/ownership/probe.d.ts +13 -0
- package/dist/ownership/probe.d.ts.map +1 -0
- package/dist/ownership/resume.d.ts +40 -0
- package/dist/ownership/resume.d.ts.map +1 -0
- package/dist/ownership/rows.d.ts +33 -0
- package/dist/ownership/rows.d.ts.map +1 -0
- package/dist/ownership/whole.d.ts +14 -0
- package/dist/ownership/whole.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +3 -3
- package/dist/proxmox/ceph-pool.d.ts.map +1 -1
- package/dist/proxmox/fake-bao-env.d.ts +3 -0
- package/dist/proxmox/fake-bao-env.d.ts.map +1 -0
- package/dist/proxmox/fake-pve-lxc.d.ts +42 -0
- package/dist/proxmox/fake-pve-lxc.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +38 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +6 -2
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +970 -186
- package/dist/proxmox/index.js.map +22 -9
- package/dist/proxmox/lxc-create-form.d.ts +6 -0
- package/dist/proxmox/lxc-create-form.d.ts.map +1 -0
- package/dist/proxmox/lxc-harness.d.ts +34 -0
- package/dist/proxmox/lxc-harness.d.ts.map +1 -0
- package/dist/proxmox/lxc-identity.d.ts +18 -0
- package/dist/proxmox/lxc-identity.d.ts.map +1 -0
- package/dist/proxmox/lxc-judge.d.ts +46 -0
- package/dist/proxmox/lxc-judge.d.ts.map +1 -0
- package/dist/proxmox/lxc-lifecycle.d.ts +68 -0
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/lxc-props.d.ts +139 -0
- package/dist/proxmox/lxc-props.d.ts.map +1 -0
- package/dist/proxmox/lxc-volume.d.ts +29 -0
- package/dist/proxmox/lxc-volume.d.ts.map +1 -0
- package/dist/proxmox/lxc-wire.d.ts +51 -0
- package/dist/proxmox/lxc-wire.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +27 -47
- package/dist/proxmox/lxc.d.ts.map +1 -1
- package/dist/proxmox/network-apply-read.d.ts +1 -1
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/update-guard.d.ts +27 -0
- package/dist/proxmox/update-guard.d.ts.map +1 -0
- package/dist/verify/args.d.ts +16 -0
- package/dist/verify/args.d.ts.map +1 -0
- package/dist/verify/cli.d.ts +3 -0
- package/dist/verify/cli.d.ts.map +1 -0
- package/dist/verify/cli.js +320 -0
- package/dist/verify/cli.js.map +15 -0
- package/dist/verify/fake-engine.d.ts +57 -0
- package/dist/verify/fake-engine.d.ts.map +1 -0
- package/dist/verify/index.d.ts +16 -0
- package/dist/verify/index.d.ts.map +1 -0
- package/dist/verify/index.js +297 -0
- package/dist/verify/index.js.map +14 -0
- package/dist/verify/report.d.ts +6 -0
- package/dist/verify/report.d.ts.map +1 -0
- package/dist/verify/rows.d.ts +57 -0
- package/dist/verify/rows.d.ts.map +1 -0
- package/dist/verify/spy.d.ts +31 -0
- package/dist/verify/spy.d.ts.map +1 -0
- package/dist/verify/verify.d.ts +43 -0
- package/dist/verify/verify.d.ts.map +1 -0
- package/docs/adopt-verify.md +108 -0
- package/docs/adopted-deploys.md +85 -0
- package/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/credentials.md +15 -0
- package/docs/launchd-sudo.md +147 -0
- package/docs/launchd.md +14 -7
- package/docs/ownership.md +113 -0
- package/docs/peers.md +39 -0
- package/docs/proxmox-lxc.md +196 -0
- package/docs/proxmox.md +12 -0
- package/package.json +9 -2
- package/src/caddy/adopt-scope.test.ts +82 -0
- package/src/caddy/config-adopt.test.ts +235 -0
- package/src/caddy/config-lifecycle.test.ts +31 -53
- package/src/caddy/config-lifecycle.ts +98 -7
- package/src/caddy/config.ts +48 -10
- package/src/caddy/fake-caddy.ts +19 -0
- package/src/caddy/index.ts +2 -1
- package/src/caddy/providers.test.ts +8 -23
- package/src/caddy/with-file.ts +7 -3
- package/src/launchd/adopt-apply.test.ts +166 -0
- package/src/launchd/fake-runner.ts +26 -4
- package/src/launchd/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +1 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +41 -15
- package/src/launchd/host-file.ts +13 -4
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-lifecycle.ts +22 -14
- package/src/launchd/job-preflight.ts +21 -3
- package/src/launchd/job-validate.ts +1 -1
- package/src/launchd/job.ts +13 -4
- package/src/launchd/local-runner.ts +6 -3
- package/src/launchd/providers.test.ts +3 -0
- package/src/launchd/providers.ts +2 -1
- package/src/launchd/runner.ts +14 -5
- package/src/launchd/sudo-acl.ts +68 -0
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-chain.test.ts +121 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +230 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -0
- package/src/launchd/sudo-modes.test.ts +135 -0
- package/src/launchd/sudo-runner-files.test.ts +240 -0
- package/src/launchd/sudo-runner.test.ts +126 -0
- package/src/launchd/sudo-runner.ts +216 -0
- package/src/launchd/sudo-said.ts +58 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +11 -2
- package/src/openbao/REPLACE.md +67 -37
- package/src/openbao/adopt-core.test.ts +119 -0
- package/src/openbao/adopt-families.test.ts +97 -0
- package/src/openbao/adopt-holes.test.ts +168 -0
- package/src/openbao/adopt-recovery.test.ts +71 -0
- package/src/openbao/auth-method-reconcile.ts +15 -3
- package/src/openbao/auth-method.ts +15 -6
- package/src/openbao/auth-role-form.test.ts +2 -16
- package/src/openbao/auth-role-form.ts +0 -11
- package/src/openbao/auth-role.ts +48 -17
- package/src/openbao/cloudflare-role.ts +23 -18
- package/src/openbao/fake-bao.ts +52 -0
- package/src/openbao/fake-core.ts +141 -0
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +188 -0
- package/src/openbao/fake-mounts.ts +74 -0
- package/src/openbao/fake-stack.ts +17 -4
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +35 -11
- package/src/openbao/jwt-role.ts +33 -14
- package/src/openbao/kubernetes-role.ts +32 -14
- package/src/openbao/mfa-enforcement.ts +42 -13
- package/src/openbao/mfa-totp.ts +42 -14
- package/src/openbao/mount-move.test.ts +2 -35
- package/src/openbao/mount-reconcile.ts +13 -2
- package/src/openbao/mount.ts +15 -6
- package/src/openbao/pki-role.ts +33 -10
- package/src/openbao/plugin-reconcile.ts +16 -5
- package/src/openbao/plugin.ts +57 -20
- package/src/openbao/policy.ts +24 -11
- package/src/openbao/proxmox-role.ts +20 -20
- package/src/openbao/rename-adoption.test.ts +95 -0
- package/src/openbao/rename-families.test.ts +122 -0
- package/src/openbao/rename-identity.test.ts +129 -0
- package/src/openbao/rename-identity.ts +117 -0
- package/src/openbao/rename.ts +19 -15
- package/src/openbao/role-reconcile.ts +23 -0
- package/src/openbao/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +29 -57
- package/src/ownership/adopt.ts +122 -0
- package/src/ownership/ownership.test.ts +200 -0
- package/src/ownership/probe.ts +65 -0
- package/src/ownership/resume.ts +118 -0
- package/src/ownership/rows.ts +117 -0
- package/src/ownership/whole.test.ts +39 -0
- package/src/ownership/whole.ts +60 -0
- package/src/proxmox/adopt-noop.test.ts +112 -0
- package/src/proxmox/ceph-pool-adopt.test.ts +88 -0
- package/src/proxmox/ceph-pool-form.ts +3 -3
- package/src/proxmox/ceph-pool.ts +10 -3
- package/src/proxmox/fake-bao-env.ts +28 -0
- package/src/proxmox/fake-pve-lxc.ts +210 -0
- package/src/proxmox/fake-pve.ts +75 -0
- package/src/proxmox/index.test.ts +6 -6
- package/src/proxmox/index.ts +6 -2
- package/src/proxmox/lxc-adopt.test.ts +170 -0
- package/src/proxmox/lxc-create-form.ts +76 -0
- package/src/proxmox/lxc-create.test.ts +126 -0
- package/src/proxmox/lxc-guards.test.ts +158 -0
- package/src/proxmox/lxc-harness.ts +150 -0
- package/src/proxmox/lxc-identity.ts +43 -0
- package/src/proxmox/lxc-judge.test.ts +175 -0
- package/src/proxmox/lxc-judge.ts +196 -0
- package/src/proxmox/lxc-lifecycle.ts +200 -0
- package/src/proxmox/lxc-ownership.test.ts +159 -0
- package/src/proxmox/lxc-props.ts +215 -0
- package/src/proxmox/lxc-read.test.ts +60 -0
- package/src/proxmox/lxc-volume.test.ts +96 -0
- package/src/proxmox/lxc-volume.ts +153 -0
- package/src/proxmox/lxc-wire.ts +210 -0
- package/src/proxmox/lxc.ts +216 -93
- package/src/proxmox/network-apply-read.ts +3 -1
- package/src/proxmox/pbs-datastore.ts +4 -3
- package/src/proxmox/resource.ts +7 -7
- package/src/proxmox/update-guard.ts +31 -0
- package/src/verify/args.ts +82 -0
- package/src/verify/cli.test.ts +86 -0
- package/src/verify/cli.ts +49 -0
- package/src/verify/fake-engine.ts +158 -0
- package/src/verify/index.ts +36 -0
- package/src/verify/report.ts +52 -0
- package/src/verify/rows.ts +169 -0
- package/src/verify/spy.test.ts +141 -0
- package/src/verify/spy.ts +149 -0
- package/src/verify/verify.test.ts +124 -0
- package/src/verify/verify.ts +172 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fakes of the stores behind the batch-2 families, for fake-bao: every role engine, JWT auth config
|
|
3
|
+
* and login-MFA enforcements (`wireRoles`), the TOTP method table (`totpMethods`) and the plugin
|
|
4
|
+
* catalog (`pluginCatalog`), behind one answer (`fakeEstate`). As in fake-engines.ts, each keys its
|
|
5
|
+
* objects the way the real server does, because a rename test is only as good as the fake's idea of
|
|
6
|
+
* "the same object".
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
9
|
+
*/
|
|
10
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
11
|
+
import { ABSENT, type Store, type Stored, lastSegment, store } from './fake-engines.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The role stores whose server lowercases the name: AppRole (openbao v2.6.2
|
|
15
|
+
* builtin/credential/approle/path_role.go:1485 and :1893), Kubernetes
|
|
16
|
+
* (builtin/credential/kubernetes backend.go:362, path_role.go:250 and :396) and JWT, whose `name`
|
|
17
|
+
* field is a TypeLowerCaseString (builtin/credential/jwt/path_role.go:77; sdk v2.6.2
|
|
18
|
+
* framework/field_data.go:250-255 lowercases it on read). PKI and SSH store the name verbatim. The
|
|
19
|
+
* tests mount all three at their default paths.
|
|
20
|
+
*/
|
|
21
|
+
const FOLDED = /^auth\/(approle|jwt|kubernetes)\/role\/[^/]+$/;
|
|
22
|
+
|
|
23
|
+
const keyOf = (path: string): string =>
|
|
24
|
+
FOLDED.test(path) ? path.replace(/[^/]+$/, (name) => name.toLowerCase()) : path;
|
|
25
|
+
|
|
26
|
+
/** Fields a write sends comma-joined that the server reads back as a list. */
|
|
27
|
+
const LISTS = new Set([
|
|
28
|
+
'allowed_domains',
|
|
29
|
+
'cn_validations',
|
|
30
|
+
'ext_key_usage',
|
|
31
|
+
'key_usage',
|
|
32
|
+
'token_policies',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The server's typed view of a `k=v` body: `true`, `false` and digit strings become values, and the
|
|
37
|
+
* LISTS split. Arrays, objects and duration strings pass through, and every reader here takes a
|
|
38
|
+
* duration string as well as seconds (`ttlSeconds`).
|
|
39
|
+
*/
|
|
40
|
+
const typed = (body: Stored): Stored => {
|
|
41
|
+
const out: Stored = {};
|
|
42
|
+
for (const [field, value] of Object.entries(body)) {
|
|
43
|
+
if (typeof value !== 'string') out[field] = value;
|
|
44
|
+
else if (LISTS.has(field)) out[field] = value.split(',').filter((part) => part !== '');
|
|
45
|
+
else if (value === 'true' || value === 'false') out[field] = value === 'true';
|
|
46
|
+
else out[field] = /^\d+$/.test(value) ? Number(value) : value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Every role engine, JWT auth config and MFA login enforcement. A write replaces the object. */
|
|
52
|
+
export const wireRoles = (): Store =>
|
|
53
|
+
store<Stored>(
|
|
54
|
+
(_path, body) => typed(body),
|
|
55
|
+
(path) => keyOf(path),
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* `sys/plugins/catalog/<type>/<name>`. A register names its version in the body; a read or a delete
|
|
60
|
+
* names it in `?version=` (plugin-form.ts versionedPath).
|
|
61
|
+
*/
|
|
62
|
+
export const pluginCatalog = (): Store =>
|
|
63
|
+
store<Stored>(
|
|
64
|
+
(path, body) => ({ ...body, builtin: false, name: lastSegment(path.split('?')[0] ?? '') }),
|
|
65
|
+
(path, body) => {
|
|
66
|
+
const version = body?.['version'];
|
|
67
|
+
return typeof version === 'string' && version !== '' ? `${path}?version=${version}` : path;
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* `identity/mfa/method/totp`: an upsert by `method_name` (identity/mfa.go:159-230), listed with
|
|
73
|
+
* every other type under `identity/mfa/method?list=true` (mfa-wire.ts). Keyed by the server-made
|
|
74
|
+
* id.
|
|
75
|
+
*/
|
|
76
|
+
export const totpMethods = (): Store => {
|
|
77
|
+
const live = new Map<string, Stored>();
|
|
78
|
+
let made = 0;
|
|
79
|
+
const answer = (seen: Seen): Reply => {
|
|
80
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
81
|
+
if (seen.method === 'POST') {
|
|
82
|
+
const body = JSON.parse(seen.body) as Stored;
|
|
83
|
+
const name = body['method_name'];
|
|
84
|
+
const found = [...live].find(([, method]) => method['name'] === name)?.[0];
|
|
85
|
+
made += found === undefined ? 1 : 0;
|
|
86
|
+
const id = found ?? `method-${String(made)}`;
|
|
87
|
+
live.set(id, { ...body, id, name, namespace_path: '', type: 'totp' });
|
|
88
|
+
return { json: { data: { method_id: id } }, status: 200 };
|
|
89
|
+
}
|
|
90
|
+
if (seen.method === 'DELETE') {
|
|
91
|
+
live.delete(lastSegment(path));
|
|
92
|
+
return { status: 204 };
|
|
93
|
+
}
|
|
94
|
+
if (path !== 'identity/mfa/method?list=true' || live.size === 0) return ABSENT;
|
|
95
|
+
const info = Object.fromEntries(live);
|
|
96
|
+
return { json: { data: { key_info: info, keys: [...live.keys()] } }, status: 200 };
|
|
97
|
+
};
|
|
98
|
+
return Object.assign(answer, { live });
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** All three behind one answer, routed by path, for a stack that mixes the families. */
|
|
102
|
+
export const fakeEstate = () => {
|
|
103
|
+
const roles = wireRoles();
|
|
104
|
+
const plugins = pluginCatalog();
|
|
105
|
+
const totp = totpMethods();
|
|
106
|
+
const answer = (seen: Seen): Reply => {
|
|
107
|
+
if (seen.path.startsWith('/v1/identity/mfa/method')) return totp(seen);
|
|
108
|
+
if (seen.path.startsWith('/v1/sys/plugins/catalog/')) return plugins(seen);
|
|
109
|
+
return roles(seen);
|
|
110
|
+
};
|
|
111
|
+
return Object.assign(answer, { plugins, roles, totp });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type Estate = ReturnType<typeof fakeEstate>;
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Fakes of the three stores behind Bao.Policy, Bao.CloudflareRole and Bao.ProxmoxRole, for fake-bao,
|
|
3
3
|
* plus Bao.Policy's provider over fake fragment directories. Each store keys its objects the way the
|
|
4
4
|
* real server does. That is the point: a rename test is only as good as the fake's idea of "the
|
|
5
|
-
* same object".
|
|
5
|
+
* same object". `store` is shared with fake-engines-roles.ts, which fakes the batch-2 families.
|
|
6
6
|
*
|
|
7
7
|
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
8
8
|
*/
|
|
@@ -17,26 +17,33 @@ import { trimTrailing } from './mount-path.ts';
|
|
|
17
17
|
import { BaoPolicyProvider } from './policy.ts';
|
|
18
18
|
import { policyKey } from './rename.ts';
|
|
19
19
|
|
|
20
|
-
type Stored = Record<string, unknown>;
|
|
20
|
+
export type Stored = Record<string, unknown>;
|
|
21
21
|
|
|
22
22
|
/** An answer for fake-bao that also exposes what it holds, keyed by API path (no `/v1/`). */
|
|
23
23
|
export type Store = ((seen: Seen) => Reply) & { readonly live: Map<string, Stored> };
|
|
24
24
|
|
|
25
|
-
const ABSENT: Reply = { json: { errors: [] }, status: 404 };
|
|
25
|
+
export const ABSENT: Reply = { json: { errors: [] }, status: 404 };
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
/**
|
|
28
|
+
* A store of objects by API path. `key` turns a request path (and a write's body) into the key the
|
|
29
|
+
* server would store it under.
|
|
30
|
+
*/
|
|
31
|
+
export const store = <B extends Stored = Record<string, string>>(
|
|
32
|
+
write: (path: string, body: B, before: Stored | undefined) => Stored,
|
|
33
|
+
key: (path: string, body?: B) => string = (path) => path,
|
|
30
34
|
): Store => {
|
|
31
35
|
const live = new Map<string, Stored>();
|
|
32
36
|
const answer = (seen: Seen): Reply => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
const raw = seen.path.replace(/^\/v1\//, '');
|
|
38
|
+
// ★ `bao write` sends PUT, and the identity endpoints are called with POST. Both write.
|
|
39
|
+
if (seen.method === 'PUT' || seen.method === 'POST') {
|
|
40
|
+
const body = JSON.parse(seen.body) as B;
|
|
41
|
+
const path = key(raw, body);
|
|
36
42
|
live.set(path, write(path, body, live.get(path)));
|
|
37
43
|
return { status: 204 };
|
|
38
44
|
}
|
|
39
|
-
|
|
45
|
+
const path = key(raw);
|
|
46
|
+
// ★ A delete answers 204 whether or not the object existed, as every engine here does.
|
|
40
47
|
if (seen.method === 'DELETE') {
|
|
41
48
|
live.delete(path);
|
|
42
49
|
return { status: 204 };
|
|
@@ -47,7 +54,7 @@ const store = (
|
|
|
47
54
|
return Object.assign(answer, { live });
|
|
48
55
|
};
|
|
49
56
|
|
|
50
|
-
const lastSegment = (path: string) => path.split('/').at(-1) ?? '';
|
|
57
|
+
export const lastSegment = (path: string): string => path.split('/').at(-1) ?? '';
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
60
|
* `sys/policies/acl/<name>`. It strips the policy's trailing newline on write (measured on the live
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nine batch-2 families as rows of one table, so a rename test can run each of them through
|
|
3
|
+
* Alchemy's own plan and apply (fake-stack.ts) against one fake (fake-engines-roles.ts). Every row
|
|
4
|
+
* declares a resource whose identity is `name` (for Bao.JwtAuthConfig, the mount), with `knob`, a
|
|
5
|
+
* duration, in a prop that is NOT its identity: the pending Output in "a rename lands with another
|
|
6
|
+
* pending prop".
|
|
7
|
+
*
|
|
8
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
9
|
+
*/
|
|
10
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
11
|
+
import type * as Output from 'alchemy/Output';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
|
+
import { BaoAuthRole, BaoAuthRoleProvider } from './auth-role.ts';
|
|
16
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
17
|
+
import { type Estate, fakeEstate } from './fake-engines-roles.ts';
|
|
18
|
+
import { type FakeStack, type StackBody, fakeStack, withFakeStack } from './fake-stack.ts';
|
|
19
|
+
import { BaoJwtAuthConfig, BaoJwtAuthConfigProvider } from './jwt-config.ts';
|
|
20
|
+
import { BaoJwtRole, BaoJwtRoleProvider } from './jwt-role.ts';
|
|
21
|
+
import { BaoKubernetesRole, BaoKubernetesRoleProvider } from './kubernetes-role.ts';
|
|
22
|
+
import { BaoMfaLoginEnforcement, BaoMfaLoginEnforcementProvider } from './mfa-enforcement.ts';
|
|
23
|
+
import { BaoMfaTotpMethod, BaoMfaTotpMethodProvider } from './mfa-totp.ts';
|
|
24
|
+
import { BaoPkiRole, BaoPkiRoleProvider } from './pki-role.ts';
|
|
25
|
+
import { BaoPlugin, BaoPluginProvider } from './plugin.ts';
|
|
26
|
+
import { BaoSshRole, BaoSshRoleProvider } from './ssh-role.ts';
|
|
27
|
+
|
|
28
|
+
export const familyProviders = Layer.mergeAll(
|
|
29
|
+
BaoAuthRoleProvider(),
|
|
30
|
+
BaoJwtAuthConfigProvider(),
|
|
31
|
+
BaoJwtRoleProvider(),
|
|
32
|
+
BaoKubernetesRoleProvider(),
|
|
33
|
+
BaoMfaLoginEnforcementProvider(),
|
|
34
|
+
BaoMfaTotpMethodProvider(),
|
|
35
|
+
BaoPkiRoleProvider(),
|
|
36
|
+
BaoPluginProvider(),
|
|
37
|
+
BaoSshRoleProvider(),
|
|
38
|
+
FetchHttpClient.layer,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* One stack over a fresh estate: every family's provider, and every call the fake saw — plus
|
|
43
|
+
* `owner`, a SECOND stack over the same fake with its own state: another owner of the same names.
|
|
44
|
+
*/
|
|
45
|
+
export const withEstate = (
|
|
46
|
+
body: (stack: FakeStack, estate: Estate, seen: Seen[], owner: FakeStack) => Promise<void>,
|
|
47
|
+
answer: (estate: Estate) => (seen: Seen) => Reply = (estate) => estate,
|
|
48
|
+
): Promise<void> => {
|
|
49
|
+
const estate = fakeEstate();
|
|
50
|
+
return withFakeStack(familyProviders, answer(estate), (stack, bao) =>
|
|
51
|
+
body(stack, estate, bao.seen, fakeStack(familyProviders, { BAO_ADDR: bao.address }, 'Owner')),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** The row for one family, by name. */
|
|
56
|
+
export const rowOf = (family: string): Family => {
|
|
57
|
+
const row = FAMILIES.find((each) => each.family === family);
|
|
58
|
+
if (row === undefined) throw new Error(`no fake-families row for ${family}`);
|
|
59
|
+
return row;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type Text = string | Output.Output<string>;
|
|
63
|
+
type Removal = typeof RemovalPolicy.destroy;
|
|
64
|
+
|
|
65
|
+
export interface Family {
|
|
66
|
+
readonly family: string;
|
|
67
|
+
/** One resource, logical id `id`, whose identity is `name`. */
|
|
68
|
+
readonly declare: (id: string, name: Text, knob: Text, removal?: Removal) => StackBody;
|
|
69
|
+
/** Whether the fake holds the object `name` names. */
|
|
70
|
+
readonly has: (estate: Estate, name: string) => boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const role = (path: (name: string) => string) => (estate: Estate, name: string) =>
|
|
74
|
+
estate.roles.live.has(path(name));
|
|
75
|
+
|
|
76
|
+
const APPROLE = { secretIdTtl: '24h', tokenMaxTtl: '1h', tokenPolicies: ['default'] } as const;
|
|
77
|
+
const SHA = 'a'.repeat(64);
|
|
78
|
+
|
|
79
|
+
export const FAMILIES: readonly Family[] = [
|
|
80
|
+
{
|
|
81
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
82
|
+
BaoAuthRole(id, { ...APPROLE, name, tokenTtl: knob }).pipe(removal()),
|
|
83
|
+
family: 'Bao.AuthRole',
|
|
84
|
+
has: role((name) => `auth/approle/role/${name}`),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
88
|
+
BaoPkiRole(id, { allowedDomains: ['example.com'], maxTtl: '8760h', name, ttl: knob }).pipe(
|
|
89
|
+
removal(),
|
|
90
|
+
),
|
|
91
|
+
family: 'Bao.PkiRole',
|
|
92
|
+
has: role((name) => `pki/roles/${name}`),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
96
|
+
BaoJwtRole(id, {
|
|
97
|
+
boundAudiences: ['https://example.com'],
|
|
98
|
+
name,
|
|
99
|
+
roleType: 'jwt',
|
|
100
|
+
tokenPolicies: ['ci'],
|
|
101
|
+
tokenTtl: knob,
|
|
102
|
+
userClaim: 'sub',
|
|
103
|
+
}).pipe(removal()),
|
|
104
|
+
family: 'Bao.JwtRole',
|
|
105
|
+
has: role((name) => `auth/jwt/role/${name}`),
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
109
|
+
BaoKubernetesRole(id, {
|
|
110
|
+
aliasNameSource: 'serviceaccount_uid',
|
|
111
|
+
boundServiceAccountNames: ['app'],
|
|
112
|
+
boundServiceAccountNamespaces: ['apps'],
|
|
113
|
+
name,
|
|
114
|
+
tokenPolicies: ['app'],
|
|
115
|
+
tokenTtl: knob,
|
|
116
|
+
}).pipe(removal()),
|
|
117
|
+
family: 'Bao.KubernetesRole',
|
|
118
|
+
has: role((name) => `auth/kubernetes/role/${name}`),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
declare: (id, mount, knob, removal = RemovalPolicy.retain) =>
|
|
122
|
+
BaoJwtAuthConfig(id, {
|
|
123
|
+
boundIssuer: knob,
|
|
124
|
+
jwksUrl: 'https://issuer.example.com/keys',
|
|
125
|
+
mount,
|
|
126
|
+
}).pipe(removal()),
|
|
127
|
+
family: 'Bao.JwtAuthConfig',
|
|
128
|
+
has: role((mount) => `auth/${mount}/config`),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
132
|
+
BaoMfaTotpMethod(id, { issuer: knob, name }).pipe(removal()),
|
|
133
|
+
family: 'Bao.MfaTotpMethod',
|
|
134
|
+
has: (estate, name) => [...estate.totp.live.values()].some((each) => each['name'] === name),
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
138
|
+
BaoMfaLoginEnforcement(id, {
|
|
139
|
+
authMethodTypes: ['jwt'],
|
|
140
|
+
mfaMethodIds: [knob],
|
|
141
|
+
name,
|
|
142
|
+
}).pipe(removal()),
|
|
143
|
+
family: 'Bao.MfaLoginEnforcement',
|
|
144
|
+
has: role((name) => `identity/mfa/login-enforcement/${name}`),
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
148
|
+
BaoSshRole(id, {
|
|
149
|
+
allowUserCertificates: true,
|
|
150
|
+
allowedUsers: ['ops'],
|
|
151
|
+
maxTtl: '8h',
|
|
152
|
+
name,
|
|
153
|
+
ttl: knob,
|
|
154
|
+
}).pipe(removal()),
|
|
155
|
+
family: 'Bao.SshRole',
|
|
156
|
+
has: role((name) => `ssh/roles/${name}`),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
160
|
+
BaoPlugin(id, {
|
|
161
|
+
args: [knob],
|
|
162
|
+
command: 'plugin-example',
|
|
163
|
+
name,
|
|
164
|
+
sha256: SHA,
|
|
165
|
+
type: 'secret',
|
|
166
|
+
version: 'v1.0.0',
|
|
167
|
+
}).pipe(removal()),
|
|
168
|
+
family: 'Bao.Plugin',
|
|
169
|
+
has: (estate, name) =>
|
|
170
|
+
estate.plugins.live.has(`sys/plugins/catalog/secret/${name}?version=v1.0.0`),
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Two of one family, `X` and `Y`, under destroy: the shape a swap or a shift needs. Their knobs
|
|
176
|
+
* differ, so a generation that lands on the other's name has to write over it.
|
|
177
|
+
*/
|
|
178
|
+
export const pairOf =
|
|
179
|
+
(row: Family) =>
|
|
180
|
+
(x: string, y: string): StackBody =>
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
yield* row.declare('X', x, '15m', RemovalPolicy.destroy);
|
|
183
|
+
yield* row.declare('Y', y, '30m', RemovalPolicy.destroy);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
/** An AppRole whose `tokenTtl` attribute is the Output the other rows are fed. */
|
|
187
|
+
export const upstream = (ttl: string) =>
|
|
188
|
+
BaoAuthRole('Upstream', { ...APPROLE, name: 'upstream', tokenTtl: ttl });
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake mount table for fake-bao — `sys/mounts` (secrets engines) or `sys/auth` (auth methods):
|
|
3
|
+
* enable, tune, read, the listing a 400 is settled against (mount-wire.ts), and `sys/remount`.
|
|
4
|
+
* Split out of mount-move.test.ts so the engine-level ownership tests drive Bao.Mount and
|
|
5
|
+
* Bao.AuthMethod through the same table.
|
|
6
|
+
*
|
|
7
|
+
* ⛔ TEST-ONLY — see fake-bao.ts. No provider imports this file.
|
|
8
|
+
*/
|
|
9
|
+
import type { Reply, Seen } from './fake-bao.ts';
|
|
10
|
+
|
|
11
|
+
type Entry = { type: string; description: string; config: Record<string, unknown> };
|
|
12
|
+
|
|
13
|
+
/** A fresh mount's lease TTLs: 0, "use the system default", as the server reports them. */
|
|
14
|
+
const zero = () => ({ default_lease_ttl: 0, max_lease_ttl: 0 });
|
|
15
|
+
|
|
16
|
+
/** The table keyed `<path>/` as the server keys it; `initial` maps a bare path to its type. */
|
|
17
|
+
export const liveTable = (prefix: string, initial: Record<string, string>) => {
|
|
18
|
+
const entries = new Map<string, Entry>(
|
|
19
|
+
Object.entries(initial).map(([path, type]) => [
|
|
20
|
+
`${path}/`,
|
|
21
|
+
{ config: zero(), description: '', type },
|
|
22
|
+
]),
|
|
23
|
+
);
|
|
24
|
+
const types = () => new Map([...entries].map(([path, entry]) => [path, entry.type]));
|
|
25
|
+
const answer = (seen: Seen): Reply => {
|
|
26
|
+
const path = seen.path.replace(/^\/v1\//, '');
|
|
27
|
+
if (path === 'sys/remount' && seen.method === 'POST') {
|
|
28
|
+
const { from, to } = JSON.parse(seen.body) as { from: string; to: string };
|
|
29
|
+
const key = `${from.replace(/^auth\//, '')}/`;
|
|
30
|
+
const entry = entries.get(key);
|
|
31
|
+
if (entry === undefined) return { json: { errors: ['no matching mount'] }, status: 400 };
|
|
32
|
+
entries.delete(key);
|
|
33
|
+
entries.set(`${to.replace(/^auth\//, '')}/`, entry);
|
|
34
|
+
return { json: { data: { migration_id: 'm-1' } }, status: 200 };
|
|
35
|
+
}
|
|
36
|
+
if (path.startsWith('sys/remount/status/')) {
|
|
37
|
+
return { json: { data: { migration_info: { status: 'success' } } }, status: 200 };
|
|
38
|
+
}
|
|
39
|
+
if (path === prefix) return { json: { data: Object.fromEntries(types()) }, status: 200 };
|
|
40
|
+
const tune = path.endsWith('/tune');
|
|
41
|
+
const key = `${path.slice(prefix.length + 1).replace(/\/tune$/, '')}/`;
|
|
42
|
+
if (seen.method === 'POST') {
|
|
43
|
+
const body = JSON.parse(seen.body || '{}') as Record<string, unknown>;
|
|
44
|
+
const entry = entries.get(key) ?? {
|
|
45
|
+
config: zero(),
|
|
46
|
+
description: '',
|
|
47
|
+
type: 'enabled-by-test',
|
|
48
|
+
};
|
|
49
|
+
if (!tune && typeof body['type'] === 'string') entry.type = body['type'];
|
|
50
|
+
if (tune) {
|
|
51
|
+
const { description, ...config } = body;
|
|
52
|
+
if (typeof description === 'string') entry.description = description;
|
|
53
|
+
entry.config = { ...entry.config, ...config };
|
|
54
|
+
}
|
|
55
|
+
entries.set(key, entry);
|
|
56
|
+
return { status: 204 };
|
|
57
|
+
}
|
|
58
|
+
if (seen.method === 'DELETE') {
|
|
59
|
+
entries.delete(key);
|
|
60
|
+
return { status: 204 };
|
|
61
|
+
}
|
|
62
|
+
const entry = entries.get(key);
|
|
63
|
+
return entry === undefined
|
|
64
|
+
? { json: { errors: [`No secret engine mount at ${key}`] }, status: 400 }
|
|
65
|
+
: { json: { data: entry }, status: 200 };
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
answer,
|
|
69
|
+
/** The live table as `<path>/` → type, the shape the move tests compare. */
|
|
70
|
+
get table() {
|
|
71
|
+
return types();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* in the shell running the tests is never read. No provider imports this file.
|
|
17
17
|
*/
|
|
18
18
|
import * as Alchemy from 'alchemy';
|
|
19
|
+
import { AdoptPolicy } from 'alchemy/AdoptPolicy';
|
|
20
|
+
import { provideFreshArtifactStore } from 'alchemy/Artifacts';
|
|
19
21
|
import * as Effect from 'effect/Effect';
|
|
20
22
|
import * as Layer from 'effect/Layer';
|
|
21
23
|
import type { BaoEnvironment } from './bao-address.ts';
|
|
@@ -28,9 +30,12 @@ export type Planned = Readonly<Record<string, string>>;
|
|
|
28
30
|
/** A stack body: resource declarations, as an `alchemy.run.ts` would write them. */
|
|
29
31
|
export type StackBody = Effect.Effect<unknown, unknown, unknown>;
|
|
30
32
|
|
|
33
|
+
/** What the CLI would set for one deploy: `adopt` is `--adopt` (the AdoptPolicy service). */
|
|
34
|
+
export type DeployOptions = { readonly adopt?: boolean };
|
|
35
|
+
|
|
31
36
|
export interface FakeStack {
|
|
32
37
|
/** Plan, then apply, `body`. Resolves to the plan; rejects with the apply's failure. */
|
|
33
|
-
readonly deploy: (body: StackBody) => Promise<Planned>;
|
|
38
|
+
readonly deploy: (body: StackBody, options?: DeployOptions) => Promise<Planned>;
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
type Node = { readonly action: string };
|
|
@@ -72,22 +77,30 @@ export const fakeStack = <ROut, E, RIn>(
|
|
|
72
77
|
) => Effect.Effect<PlanView, unknown, never>;
|
|
73
78
|
const apply = Alchemy.apply as unknown as (planned: PlanView) => Effect.Effect<unknown, unknown>;
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
/**
|
|
81
|
+
* ★ ONE ARTIFACT STORE ACROSS PLAN AND APPLY, as Deploy.ts's evalStack provides — the channel a
|
|
82
|
+
* diff uses to tell reconcile it resumes an interrupted create (ownership/resume.ts).
|
|
83
|
+
* ★ `--adopt` reaches registration, plan and apply alike, as the Alchemist session provides it.
|
|
84
|
+
*/
|
|
85
|
+
const deploy = (body: StackBody, options: DeployOptions = {}) =>
|
|
76
86
|
Effect.gen(function* () {
|
|
77
87
|
const compiled = yield* stack(name, { providers, state }, body);
|
|
78
88
|
return yield* Effect.gen(function* () {
|
|
79
89
|
const planned = yield* plan(compiled);
|
|
80
90
|
yield* apply(planned);
|
|
81
91
|
return actionsOf(planned);
|
|
82
|
-
}).pipe(Effect.provide(Layer.succeedContext(compiled.services)));
|
|
92
|
+
}).pipe(provideFreshArtifactStore, Effect.provide(Layer.succeedContext(compiled.services)));
|
|
83
93
|
}).pipe(
|
|
94
|
+
options.adopt === undefined ? (e) => e : Effect.provideService(AdoptPolicy, options.adopt),
|
|
84
95
|
Effect.provideService(Alchemy.Stage, 'test'),
|
|
85
96
|
Effect.provideService(BaoEnv, env),
|
|
86
97
|
Effect.provide(state),
|
|
87
98
|
Effect.scoped,
|
|
88
99
|
);
|
|
89
100
|
|
|
90
|
-
return {
|
|
101
|
+
return {
|
|
102
|
+
deploy: (body, options) => Effect.runPromise(deploy(body, options) as Effect.Effect<Planned>),
|
|
103
|
+
};
|
|
91
104
|
};
|
|
92
105
|
|
|
93
106
|
/**
|
|
@@ -80,9 +80,21 @@ describe('hostAppRoles', () => {
|
|
|
80
80
|
}
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
-
it('
|
|
83
|
+
it('gives a host in two classes one role per class', () => {
|
|
84
|
+
const hosts = [
|
|
85
|
+
{ class: 'pve-node', name: 'node-c' },
|
|
86
|
+
{ class: 'operator-signer', name: 'node-c' },
|
|
87
|
+
];
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
hostAppRoles({ ...INPUT, hosts }).map((role) => role.name),
|
|
90
|
+
['operator-signer--node-c', 'pve-node--node-c'],
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('refuses unknown classes, a host twice in one class and names that blur the separator', () => {
|
|
84
95
|
const bad = [
|
|
85
96
|
{ class: 'nope', name: 'node-c' },
|
|
97
|
+
{ class: 'constructor', name: 'node-d' },
|
|
86
98
|
{ class: 'pve-node', name: 'node-a' },
|
|
87
99
|
{ class: 'pve-node', name: 'node-a' },
|
|
88
100
|
{ class: 'pve-node', name: 'node--x' },
|
|
@@ -93,7 +105,8 @@ describe('hostAppRoles', () => {
|
|
|
93
105
|
(error: unknown) => {
|
|
94
106
|
assert.ok(error instanceof Error);
|
|
95
107
|
assert.match(error.message, /unknown class `nope`/);
|
|
96
|
-
assert.match(error.message,
|
|
108
|
+
assert.match(error.message, /unknown class `constructor`/);
|
|
109
|
+
assert.match(error.message, /`node-a` is listed twice in class `pve-node`/);
|
|
97
110
|
assert.match(error.message, /`node--x` is not lowercase/);
|
|
98
111
|
assert.match(error.message, /`Node-Y` is not lowercase/);
|
|
99
112
|
return true;
|
|
@@ -96,22 +96,37 @@ export const hostAppRoles = (input: HostAppRolesInput): readonly BaoAuthRoleProp
|
|
|
96
96
|
const problems = Object.entries(input.classes).flatMap(([name, spec]) =>
|
|
97
97
|
classProblems(name, spec),
|
|
98
98
|
);
|
|
99
|
+
/**
|
|
100
|
+
* ⛔ DEDUPED ON THE ROLE NAME, NOT THE HOST NAME. A host may sit in several classes (one machine
|
|
101
|
+
* that is both a signer and a node), and each class gives it its own role: `<class>--<host>`
|
|
102
|
+
* differs, so nothing collides. Until 2026-09-21 this keyed on the host alone and refused that
|
|
103
|
+
* estate outright. What it must still refuse is the same host twice in ONE class: two
|
|
104
|
+
* declarations of one role name, so one AppRole that two resources would each claim.
|
|
105
|
+
*/
|
|
99
106
|
const seen = new Set<string>();
|
|
100
107
|
const roles: BaoAuthRoleProps[] = [];
|
|
101
108
|
for (const host of input.hosts) {
|
|
102
109
|
if (!NAME.test(host.name)) {
|
|
103
110
|
problems.push(`host \`${host.name}\` is not lowercase letters, digits, single hyphens`);
|
|
104
111
|
}
|
|
105
|
-
|
|
106
|
-
seen.
|
|
107
|
-
|
|
112
|
+
const name = hostRoleName(host.class, host.name);
|
|
113
|
+
if (seen.has(name)) {
|
|
114
|
+
problems.push(`host \`${host.name}\` is listed twice in class \`${host.class}\``);
|
|
115
|
+
}
|
|
116
|
+
seen.add(name);
|
|
117
|
+
/**
|
|
118
|
+
* ⛔ OWN KEYS ONLY. `classes['constructor']` is Object's constructor, not undefined, so a host
|
|
119
|
+
* naming it passed as a class with no policies and no secretIdTtl, never checked by
|
|
120
|
+
* classProblems, and wrote a role whose secret_id never expires.
|
|
121
|
+
*/
|
|
122
|
+
const spec = Object.hasOwn(input.classes, host.class) ? input.classes[host.class] : undefined;
|
|
108
123
|
if (spec === undefined) {
|
|
109
124
|
problems.push(`host \`${host.name}\` names unknown class \`${host.class}\``);
|
|
110
125
|
continue;
|
|
111
126
|
}
|
|
112
127
|
roles.push({
|
|
113
128
|
bindSecretId: true,
|
|
114
|
-
name
|
|
129
|
+
name,
|
|
115
130
|
secretIdNumUses: spec.secretIdNumUses ?? 0,
|
|
116
131
|
secretIdTtl: spec.secretIdTtl,
|
|
117
132
|
tokenMaxTtl: spec.tokenMaxTtl,
|
|
@@ -10,24 +10,32 @@
|
|
|
10
10
|
*
|
|
11
11
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` changed → `replace`: the new mount gets the config;
|
|
12
12
|
* the old one keeps its own until that mount is disabled. There is no identity but the mount.
|
|
13
|
+
* ⛔ A MOVE ONTO A MOUNT WHOSE CONFIG EXISTS FAILS THE PLAN, like every other family's move onto an
|
|
14
|
+
* occupied path (rename-identity.ts). With no delete, nothing is lost to a swap, which MEASURED
|
|
15
|
+
* 2026-09-21 ended with each config where it was declared. But the new generation writes over
|
|
16
|
+
* the config it lands on, which changes who can log in on that mount: an issuer or key set
|
|
17
|
+
* someone else manages, silently replaced. Move through a mount with no config instead.
|
|
13
18
|
*/
|
|
14
19
|
import { Resource } from 'alchemy';
|
|
15
20
|
import { isResolved } from 'alchemy/Diff';
|
|
16
21
|
import * as Provider from 'alchemy/Provider';
|
|
17
22
|
import * as Effect from 'effect/Effect';
|
|
18
23
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
24
|
+
import { claimFor } from '../ownership/adopt.ts';
|
|
25
|
+
import { provingResumes } from '../ownership/resume.ts';
|
|
19
26
|
import {
|
|
20
27
|
type BaoJwtAuthConfigAttributes,
|
|
21
28
|
type BaoJwtAuthConfigProps,
|
|
22
29
|
attributesOf,
|
|
23
30
|
configPath,
|
|
24
31
|
matches,
|
|
25
|
-
mountOf,
|
|
26
32
|
problems,
|
|
27
33
|
wouldErase,
|
|
28
34
|
writeBody,
|
|
29
35
|
} from './jwt-config-form.ts';
|
|
30
|
-
import {
|
|
36
|
+
import { declaredOr } from './rename.ts';
|
|
37
|
+
import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
|
|
38
|
+
import { type RoleSpec, planRole, readOwnedRole, reconcileRole } from './role-reconcile.ts';
|
|
31
39
|
|
|
32
40
|
export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
|
|
33
41
|
|
|
@@ -43,6 +51,16 @@ export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig',
|
|
|
43
51
|
defaultRemovalPolicy: 'retain',
|
|
44
52
|
});
|
|
45
53
|
|
|
54
|
+
/** The identity is the mount alone; a trailing `/` is the same mount (`configPath` trims it). */
|
|
55
|
+
const IDENTITY: Identity<BaoJwtAuthConfigAttributes> = {
|
|
56
|
+
declared: (props) => {
|
|
57
|
+
const mount = declaredOr(props, 'mount', 'jwt');
|
|
58
|
+
return mount === undefined ? undefined : configPath({ mount });
|
|
59
|
+
},
|
|
60
|
+
family: 'Bao.JwtAuthConfig',
|
|
61
|
+
recorded: (output) => configPath(output),
|
|
62
|
+
};
|
|
63
|
+
|
|
46
64
|
export const jwtConfigSpec = (
|
|
47
65
|
props: BaoJwtAuthConfigProps,
|
|
48
66
|
): RoleSpec<BaoJwtAuthConfigAttributes> => ({
|
|
@@ -62,24 +80,30 @@ export const BaoJwtAuthConfigProvider = () =>
|
|
|
62
80
|
BaoJwtAuthConfig.Provider.of({
|
|
63
81
|
list: () => Effect.succeed([]),
|
|
64
82
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return
|
|
83
|
+
/** ⛔ Stateless: `Unowned` unless our own interrupted create made it (ownership/probe.ts). */
|
|
84
|
+
read: Effect.fn(function* ({ fqn, instanceId, olds, output }) {
|
|
85
|
+
return yield* readOwnedRole({ fqn, instanceId, output }, jwtConfigSpec(olds));
|
|
68
86
|
}),
|
|
69
87
|
|
|
70
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
71
|
-
|
|
72
|
-
|
|
88
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
89
|
+
// ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
|
|
90
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
91
|
+
// ★ No attributes: an unfinished generation, proven ours or not by provingResumes.
|
|
92
|
+
if (output === undefined) return undefined;
|
|
93
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
94
|
+
if (!isResolved(news)) return undefined;
|
|
73
95
|
return { action: yield* planRole(jwtConfigSpec(news)) } as const;
|
|
74
96
|
}),
|
|
75
97
|
|
|
76
98
|
/** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
|
|
77
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
78
|
-
|
|
99
|
+
reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
|
|
100
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
101
|
+
yield* guardRename(IDENTITY, news, output);
|
|
102
|
+
return yield* reconcileRole(jwtConfigSpec(news), claimFor({ fqn, instanceId, output }));
|
|
79
103
|
}),
|
|
80
104
|
|
|
81
105
|
/** ⚠️ Writes nothing — there is no delete endpoint (header). */
|
|
82
106
|
delete: () => Effect.void,
|
|
83
107
|
}),
|
|
84
|
-
),
|
|
108
|
+
).pipe(Effect.map(provingResumes)),
|
|
85
109
|
);
|