@homeflare/alchemy 0.5.0 → 0.7.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 +68 -55
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +2 -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 +24 -0
- package/dist/launchd/secret-tripwire.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-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-role.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- 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 +19 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1518 -310
- package/dist/openbao/index.js.map +40 -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 +8 -4
- package/dist/openbao/mount-path.d.ts.map +1 -1
- 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/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.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/docs/caddy.md +180 -0
- package/docs/launchd.md +167 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +12 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +2 -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 +163 -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 +16 -1
- 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-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-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +19 -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 +11 -1
- package/src/openbao/mount-path.ts +17 -11
- package/src/openbao/mount-reconcile.ts +75 -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/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form half of Bao.KubernetesRole — `auth/<mount>/role/<name>` on a `kubernetes` auth mount:
|
|
3
|
+
* which service accounts, in which namespaces, may log in, and what their token carries.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ FROM openbao v2.6.2 builtin/credential/kubernetes/path_role.go, NOT RECALLED:
|
|
6
|
+
* · The write MERGES for names, namespaces, audience and alias source (`GetOk`, :335-392), but
|
|
7
|
+
* `bound_service_account_namespace_selector` is `data.Get` — RESET when omitted (:353). Every
|
|
8
|
+
* field is therefore sent on every write, defaults included.
|
|
9
|
+
* · Names may not be empty, and `*` may not be mixed with other names; namespaces may be empty
|
|
10
|
+
* only when a selector is set, with the same `*` rule (:335-372). Refused here first.
|
|
11
|
+
* · Roles are stored under the LOWER-CASED name (:396, backend.go:361), so a mixed-case name
|
|
12
|
+
* would read back as a different string. Refused, not folded.
|
|
13
|
+
* · `audience` is omitted from a read when empty (:206-208); read as `''`.
|
|
14
|
+
* ⚠️ `aliasNameSource` DECIDES WHO A POD IS. `serviceaccount_uid` (OpenBao's default) names the
|
|
15
|
+
* entity alias by the service account's UID; `serviceaccount_name` by `<namespace>/<name>`
|
|
16
|
+
* (backend.go:30-34). Changing it on a live role re-names every pod's alias, so their next logins
|
|
17
|
+
* land on NEW entities and attribution splits.
|
|
18
|
+
* ⛔ SO IT IS REQUIRED, NOT DEFAULTED (review 2026-09-21). Every field is sent on every write, so a
|
|
19
|
+
* default here would be WRITTEN: adopting a hand-made `serviceaccount_name` role with the prop
|
|
20
|
+
* left out would plan a bare `update` and silently re-key every pod onto new entities. The
|
|
21
|
+
* machine-access plan chose `serviceaccount_name`; the server's default is the other one — a
|
|
22
|
+
* choice this important is made in the declaration, the way Bao.JwtRole's `roleType` is.
|
|
23
|
+
*/
|
|
24
|
+
import {
|
|
25
|
+
type BaoTokenForm,
|
|
26
|
+
type BaoTokenProps,
|
|
27
|
+
tokenBody,
|
|
28
|
+
tokenFields,
|
|
29
|
+
tokenFormOfLive,
|
|
30
|
+
tokenFormOfProps,
|
|
31
|
+
tokenProblems,
|
|
32
|
+
} from './auth-token-form.ts';
|
|
33
|
+
import { sha256 } from './digest.ts';
|
|
34
|
+
import { mountPath } from './mount-form.ts';
|
|
35
|
+
|
|
36
|
+
export type BaoKubernetesAliasSource = 'serviceaccount_name' | 'serviceaccount_uid';
|
|
37
|
+
|
|
38
|
+
export interface BaoKubernetesRoleProps extends BaoTokenProps {
|
|
39
|
+
/** Auth mount path. Default `kubernetes`. */
|
|
40
|
+
mount?: string;
|
|
41
|
+
/** Role name — lower case, as the server stores it. */
|
|
42
|
+
name: string;
|
|
43
|
+
/** Service account names; `['*']` for any. Never empty. */
|
|
44
|
+
boundServiceAccountNames: readonly string[];
|
|
45
|
+
/** Namespaces; `['*']` for any. May be empty only with a selector. */
|
|
46
|
+
boundServiceAccountNamespaces?: readonly string[];
|
|
47
|
+
/** A label selector (JSON or YAML) for namespaces, ORed with the list. */
|
|
48
|
+
boundServiceAccountNamespaceSelector?: string;
|
|
49
|
+
/** The `aud` a service account token must carry. Default none. */
|
|
50
|
+
audience?: string;
|
|
51
|
+
/** ⛔ Required — read the ⚠️ and ⛔ above before changing it on a live role. */
|
|
52
|
+
aliasNameSource: BaoKubernetesAliasSource;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BaoKubernetesRoleCanonical extends BaoTokenForm {
|
|
56
|
+
aliasNameSource: string;
|
|
57
|
+
audience: string;
|
|
58
|
+
boundServiceAccountNames: readonly string[];
|
|
59
|
+
boundServiceAccountNamespaceSelector: string;
|
|
60
|
+
boundServiceAccountNamespaces: readonly string[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** ⛔ No secret: account names, namespaces, policy names, TTLs. */
|
|
64
|
+
export interface BaoKubernetesRoleAttributes extends BaoKubernetesRoleCanonical {
|
|
65
|
+
mount: string;
|
|
66
|
+
name: string;
|
|
67
|
+
digest: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sortedSet = (values: readonly string[]) => [...new Set(values)].sort();
|
|
71
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
72
|
+
const strings = (value: unknown): readonly string[] =>
|
|
73
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
74
|
+
|
|
75
|
+
const canonical = (input: BaoKubernetesRoleCanonical): BaoKubernetesRoleCanonical => ({
|
|
76
|
+
aliasNameSource: input.aliasNameSource,
|
|
77
|
+
audience: input.audience,
|
|
78
|
+
boundServiceAccountNames: sortedSet(input.boundServiceAccountNames),
|
|
79
|
+
boundServiceAccountNamespaceSelector: input.boundServiceAccountNamespaceSelector.trim(),
|
|
80
|
+
boundServiceAccountNamespaces: sortedSet(input.boundServiceAccountNamespaces),
|
|
81
|
+
...tokenFields(input),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export const canonicalFromProps = (props: BaoKubernetesRoleProps): BaoKubernetesRoleCanonical =>
|
|
85
|
+
canonical({
|
|
86
|
+
...tokenFormOfProps(props),
|
|
87
|
+
aliasNameSource: props.aliasNameSource,
|
|
88
|
+
audience: props.audience ?? '',
|
|
89
|
+
boundServiceAccountNames: props.boundServiceAccountNames,
|
|
90
|
+
boundServiceAccountNamespaceSelector: props.boundServiceAccountNamespaceSelector ?? '',
|
|
91
|
+
boundServiceAccountNamespaces: props.boundServiceAccountNamespaces ?? [],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const canonicalFromLive = (live: Record<string, unknown>): BaoKubernetesRoleCanonical =>
|
|
95
|
+
canonical({
|
|
96
|
+
...tokenFormOfLive(live),
|
|
97
|
+
aliasNameSource: text(live['alias_name_source']),
|
|
98
|
+
audience: text(live['audience']),
|
|
99
|
+
boundServiceAccountNames: strings(live['bound_service_account_names']),
|
|
100
|
+
boundServiceAccountNamespaceSelector: text(live['bound_service_account_namespace_selector']),
|
|
101
|
+
boundServiceAccountNamespaces: strings(live['bound_service_account_namespaces']),
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
export const mountOf = (props: { mount?: string }): string =>
|
|
105
|
+
mountPath(props.mount ?? 'kubernetes');
|
|
106
|
+
|
|
107
|
+
export const rolePath = (props: { mount?: string; name: string }): string =>
|
|
108
|
+
`auth/${mountOf(props)}/role/${props.name}`;
|
|
109
|
+
|
|
110
|
+
export const attributesOf = (
|
|
111
|
+
props: BaoKubernetesRoleProps,
|
|
112
|
+
live: Record<string, unknown>,
|
|
113
|
+
): BaoKubernetesRoleAttributes => {
|
|
114
|
+
const form = canonicalFromLive(live);
|
|
115
|
+
return { ...form, digest: sha256(JSON.stringify(form)), mount: mountOf(props), name: props.name };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const matches = (
|
|
119
|
+
attributes: BaoKubernetesRoleAttributes,
|
|
120
|
+
props: BaoKubernetesRoleProps,
|
|
121
|
+
): boolean => attributes.digest === sha256(JSON.stringify(canonicalFromProps(props)));
|
|
122
|
+
|
|
123
|
+
export const writeBody = (props: BaoKubernetesRoleProps): Record<string, unknown> => ({
|
|
124
|
+
alias_name_source: props.aliasNameSource,
|
|
125
|
+
audience: props.audience ?? '',
|
|
126
|
+
bound_service_account_names: [...props.boundServiceAccountNames],
|
|
127
|
+
bound_service_account_namespace_selector: props.boundServiceAccountNamespaceSelector ?? '',
|
|
128
|
+
bound_service_account_namespaces: [...(props.boundServiceAccountNamespaces ?? [])],
|
|
129
|
+
...tokenBody(props),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const NAME = /^[a-z0-9_](?:[a-z0-9_.-]*[a-z0-9_])?$/;
|
|
133
|
+
|
|
134
|
+
const starMixed = (values: readonly string[]) => values.length > 1 && values.includes('*');
|
|
135
|
+
|
|
136
|
+
export const problems = (props: BaoKubernetesRoleProps): readonly string[] => {
|
|
137
|
+
const found = [...tokenProblems(props)];
|
|
138
|
+
const namespaces = props.boundServiceAccountNamespaces ?? [];
|
|
139
|
+
const selector = (props.boundServiceAccountNamespaceSelector ?? '').trim();
|
|
140
|
+
if (!NAME.test(props.name))
|
|
141
|
+
found.push(`name \`${props.name}\` must be lower case (stored folded)`);
|
|
142
|
+
if (props.boundServiceAccountNames.length === 0) found.push('boundServiceAccountNames is empty');
|
|
143
|
+
if (starMixed(props.boundServiceAccountNames)) found.push('`*` mixed with other account names');
|
|
144
|
+
if (namespaces.length === 0 && selector === '') {
|
|
145
|
+
found.push('boundServiceAccountNamespaces is empty and no namespace selector is set');
|
|
146
|
+
}
|
|
147
|
+
if (starMixed(namespaces)) found.push('`*` mixed with other namespaces');
|
|
148
|
+
return found;
|
|
149
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A role on a `kubernetes` auth mount — `auth/<mount>/role/<name>`: which service accounts may log
|
|
3
|
+
* in and what their token carries. METADATA ONLY. The machine-access plan gives k8s pods this
|
|
4
|
+
* instead of AppRole: one role per namespace/service account.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ NO SECRET IN PROPS OR ATTRIBUTES — see policy.ts. The mount's own config (the cluster's CA and
|
|
7
|
+
* the token reviewer JWT) is not declared here, and the reviewer JWT must never be.
|
|
8
|
+
* ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED — pathRoleDelete removes the storage entry
|
|
9
|
+
* and nothing else (path_role.go:239-256).
|
|
10
|
+
*
|
|
11
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first (the new
|
|
12
|
+
* path cannot collide). Under the default `retain` the old role stays live until removed by hand.
|
|
13
|
+
*/
|
|
14
|
+
import { Resource } from 'alchemy';
|
|
15
|
+
import { isResolved } from 'alchemy/Diff';
|
|
16
|
+
import * as Provider from 'alchemy/Provider';
|
|
17
|
+
import * as Effect from 'effect/Effect';
|
|
18
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
19
|
+
import { baoDelete } from './bao-http.ts';
|
|
20
|
+
import {
|
|
21
|
+
type BaoKubernetesRoleAttributes,
|
|
22
|
+
type BaoKubernetesRoleProps,
|
|
23
|
+
attributesOf,
|
|
24
|
+
matches,
|
|
25
|
+
mountOf,
|
|
26
|
+
problems,
|
|
27
|
+
rolePath,
|
|
28
|
+
writeBody,
|
|
29
|
+
} from './kubernetes-role-form.ts';
|
|
30
|
+
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
31
|
+
|
|
32
|
+
export type {
|
|
33
|
+
BaoKubernetesAliasSource,
|
|
34
|
+
BaoKubernetesRoleAttributes,
|
|
35
|
+
BaoKubernetesRoleProps,
|
|
36
|
+
} from './kubernetes-role-form.ts';
|
|
37
|
+
|
|
38
|
+
export interface BaoKubernetesRole extends Resource<
|
|
39
|
+
'Bao.KubernetesRole',
|
|
40
|
+
BaoKubernetesRoleProps,
|
|
41
|
+
BaoKubernetesRoleAttributes,
|
|
42
|
+
never,
|
|
43
|
+
HttpClient.HttpClient
|
|
44
|
+
> {}
|
|
45
|
+
|
|
46
|
+
export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole', {
|
|
47
|
+
defaultRemovalPolicy: 'retain',
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export const kubernetesRoleSpec = (
|
|
51
|
+
props: BaoKubernetesRoleProps,
|
|
52
|
+
): RoleSpec<BaoKubernetesRoleAttributes> => ({
|
|
53
|
+
attributesOf: (live) => attributesOf(props, live),
|
|
54
|
+
body: writeBody(props),
|
|
55
|
+
family: 'Bao.KubernetesRole',
|
|
56
|
+
matches: (attributes) => matches(attributes, props),
|
|
57
|
+
path: rolePath(props),
|
|
58
|
+
problems: problems(props),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const BaoKubernetesRoleProvider = () =>
|
|
62
|
+
Provider.effect(
|
|
63
|
+
BaoKubernetesRole,
|
|
64
|
+
Effect.succeed(
|
|
65
|
+
BaoKubernetesRole.Provider.of({
|
|
66
|
+
/** ⛔ The mount's role listing is not a list of things this owns. */
|
|
67
|
+
list: () => Effect.succeed([]),
|
|
68
|
+
|
|
69
|
+
read: Effect.fn(function* ({ olds }) {
|
|
70
|
+
const found = yield* readRoleAt(kubernetesRoleSpec(olds));
|
|
71
|
+
return found?.attributes;
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
|
|
75
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
76
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
77
|
+
if (mountOf(news) !== output.mount || news.name !== output.name) {
|
|
78
|
+
return { action: 'replace' } as const;
|
|
79
|
+
}
|
|
80
|
+
return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
|
|
81
|
+
}),
|
|
82
|
+
|
|
83
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
84
|
+
return yield* reconcileRole(kubernetesRoleSpec(news));
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
/** Idempotent as Alchemy requires — a missing role deletes as success. */
|
|
88
|
+
delete: Effect.fn(function* ({ output }) {
|
|
89
|
+
yield* baoDelete(rolePath(output));
|
|
90
|
+
return undefined;
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form half of Bao.MfaLoginEnforcement — `identity/mfa/login-enforcement/<name>`: which logins
|
|
3
|
+
* must pass which MFA methods.
|
|
4
|
+
*
|
|
5
|
+
* ★ READ FROM openbao v2.6.2 vault/identity/mfa.go handleMFALoginEnforcementUpdate (:511-646):
|
|
6
|
+
* · `mfa_method_ids` is required, and each id must exist in this namespace or a parent.
|
|
7
|
+
* · The four target lists are each `GetOk`: an omitted list KEEPS its stored value, and ANY list
|
|
8
|
+
* that is present — even empty — satisfies "one of … must be specified" (:634-636). So every
|
|
9
|
+
* list is sent on every write (a stale target is cleared), and "all four empty" — which the
|
|
10
|
+
* server would accept, and which enforces NOTHING — is refused here.
|
|
11
|
+
* · Every field is a TypeStringSlice (store.go:751-772). ⚠️ A comma string there is ONE element
|
|
12
|
+
* (sdk framework/field_data.go:343-356 WeakDecodes a string into a one-item slice), so lists
|
|
13
|
+
* go over as JSON arrays.
|
|
14
|
+
* ⚠️ A TYPE TARGET IS BROAD. `authMethodTypes: ['jwt']` matches EVERY jwt-type mount — including a
|
|
15
|
+
* GitHub Actions or Kubernetes issuer mount, whose logins cannot answer a TOTP prompt. Target
|
|
16
|
+
* mounts by path (resolved to accessors) or by identity group instead.
|
|
17
|
+
*/
|
|
18
|
+
import { sha256 } from './digest.ts';
|
|
19
|
+
import { mountPath } from './mount-form.ts';
|
|
20
|
+
|
|
21
|
+
export interface BaoMfaLoginEnforcementProps {
|
|
22
|
+
/** Enforcement name, unique per namespace. */
|
|
23
|
+
name: string;
|
|
24
|
+
/** Method ids — typically `[totp.methodId]`. Any ONE listed method passing is enough. */
|
|
25
|
+
mfaMethodIds: readonly string[];
|
|
26
|
+
/**
|
|
27
|
+
* Auth mount PATHS (`oidc`, `jwt`), resolved to their accessors at plan and deploy time.
|
|
28
|
+
* ⚠️ A plain string makes no graph edge. When the mount is a BaoAuthMethod in the same stack, pass
|
|
29
|
+
* its output (`[oidc.path]`) so it exists before this resolves — otherwise the deploy can reach
|
|
30
|
+
* this first and refuse with "no auth mount at …".
|
|
31
|
+
*/
|
|
32
|
+
authMethodPaths?: readonly string[];
|
|
33
|
+
/** Raw accessors (`auth_jwt_…`), for a mount outside this stack. */
|
|
34
|
+
authMethodAccessors?: readonly string[];
|
|
35
|
+
/** Auth mount TYPES — read the ⚠️ above first. */
|
|
36
|
+
authMethodTypes?: readonly string[];
|
|
37
|
+
/** Identity group ids whose members must pass MFA, on any mount. */
|
|
38
|
+
identityGroupIds?: readonly string[];
|
|
39
|
+
/** Identity entity ids, likewise. */
|
|
40
|
+
identityEntityIds?: readonly string[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The live shape, and the declared one once paths are resolved to accessors. */
|
|
44
|
+
export interface BaoMfaEnforcementCanonical {
|
|
45
|
+
authMethodAccessors: readonly string[];
|
|
46
|
+
authMethodTypes: readonly string[];
|
|
47
|
+
identityEntityIds: readonly string[];
|
|
48
|
+
identityGroupIds: readonly string[];
|
|
49
|
+
mfaMethodIds: readonly string[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ⛔ No secret — ids, accessors and names. */
|
|
53
|
+
export interface BaoMfaLoginEnforcementAttributes extends BaoMfaEnforcementCanonical {
|
|
54
|
+
name: string;
|
|
55
|
+
/** The server-made id. */
|
|
56
|
+
enforcementId: string;
|
|
57
|
+
namespacePath: string;
|
|
58
|
+
digest: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sortedSet = (values: readonly string[]) =>
|
|
62
|
+
[...new Set(values.map((value) => value.trim()))].filter((value) => value !== '').sort();
|
|
63
|
+
const strings = (value: unknown): readonly string[] =>
|
|
64
|
+
Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [];
|
|
65
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
66
|
+
|
|
67
|
+
const canonical = (input: BaoMfaEnforcementCanonical): BaoMfaEnforcementCanonical => ({
|
|
68
|
+
authMethodAccessors: sortedSet(input.authMethodAccessors),
|
|
69
|
+
authMethodTypes: sortedSet(input.authMethodTypes),
|
|
70
|
+
identityEntityIds: sortedSet(input.identityEntityIds),
|
|
71
|
+
identityGroupIds: sortedSet(input.identityGroupIds),
|
|
72
|
+
mfaMethodIds: sortedSet(input.mfaMethodIds),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/** The declaration, with `authMethodPaths` already resolved into `resolved` accessors. */
|
|
76
|
+
export const canonicalFromProps = (
|
|
77
|
+
props: BaoMfaLoginEnforcementProps,
|
|
78
|
+
resolved: readonly string[],
|
|
79
|
+
): BaoMfaEnforcementCanonical =>
|
|
80
|
+
canonical({
|
|
81
|
+
authMethodAccessors: [...(props.authMethodAccessors ?? []), ...resolved],
|
|
82
|
+
authMethodTypes: props.authMethodTypes ?? [],
|
|
83
|
+
identityEntityIds: props.identityEntityIds ?? [],
|
|
84
|
+
identityGroupIds: props.identityGroupIds ?? [],
|
|
85
|
+
mfaMethodIds: props.mfaMethodIds,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const canonicalFromLive = (live: Record<string, unknown>): BaoMfaEnforcementCanonical =>
|
|
89
|
+
canonical({
|
|
90
|
+
authMethodAccessors: strings(live['auth_method_accessors']),
|
|
91
|
+
authMethodTypes: strings(live['auth_method_types']),
|
|
92
|
+
identityEntityIds: strings(live['identity_entity_ids']),
|
|
93
|
+
identityGroupIds: strings(live['identity_group_ids']),
|
|
94
|
+
mfaMethodIds: strings(live['mfa_method_ids']),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export const attributesOf = (
|
|
98
|
+
name: string,
|
|
99
|
+
live: Record<string, unknown>,
|
|
100
|
+
): BaoMfaLoginEnforcementAttributes => {
|
|
101
|
+
const form = canonicalFromLive(live);
|
|
102
|
+
return {
|
|
103
|
+
...form,
|
|
104
|
+
digest: sha256(JSON.stringify(form)),
|
|
105
|
+
enforcementId: text(live['id']),
|
|
106
|
+
name,
|
|
107
|
+
namespacePath: text(live['namespace_path']),
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const matches = (
|
|
112
|
+
attributes: BaoMfaLoginEnforcementAttributes,
|
|
113
|
+
props: BaoMfaLoginEnforcementProps,
|
|
114
|
+
resolved: readonly string[],
|
|
115
|
+
): boolean => attributes.digest === sha256(JSON.stringify(canonicalFromProps(props, resolved)));
|
|
116
|
+
|
|
117
|
+
/** Every list, as a JSON array, every time — the ⚠️ on GetOk above. */
|
|
118
|
+
export const writeBody = (form: BaoMfaEnforcementCanonical): Record<string, unknown> => ({
|
|
119
|
+
auth_method_accessors: [...form.authMethodAccessors],
|
|
120
|
+
auth_method_types: [...form.authMethodTypes],
|
|
121
|
+
identity_entity_ids: [...form.identityEntityIds],
|
|
122
|
+
identity_group_ids: [...form.identityGroupIds],
|
|
123
|
+
mfa_method_ids: [...form.mfaMethodIds],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const NAME = /^\w(?:[\w.-]*\w)?$/;
|
|
127
|
+
|
|
128
|
+
export const problems = (
|
|
129
|
+
props: BaoMfaLoginEnforcementProps,
|
|
130
|
+
missingPaths: readonly string[],
|
|
131
|
+
): readonly string[] => {
|
|
132
|
+
const found: string[] = [];
|
|
133
|
+
if (!NAME.test(props.name)) found.push(`name \`${props.name}\` is not a valid enforcement name`);
|
|
134
|
+
if (sortedSet(props.mfaMethodIds).length === 0) found.push('mfaMethodIds is empty');
|
|
135
|
+
const targets = [
|
|
136
|
+
props.authMethodPaths,
|
|
137
|
+
props.authMethodAccessors,
|
|
138
|
+
props.authMethodTypes,
|
|
139
|
+
props.identityGroupIds,
|
|
140
|
+
props.identityEntityIds,
|
|
141
|
+
].some((list) => sortedSet(list ?? []).length > 0);
|
|
142
|
+
if (!targets) found.push('no target: an enforcement with none is accepted and enforces NOTHING');
|
|
143
|
+
if (missingPaths.length > 0) {
|
|
144
|
+
found.push(`no auth mount at ${missingPaths.map((path) => mountPath(path)).join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A login-MFA enforcement — `identity/mfa/login-enforcement/<name>`: logins through these mounts
|
|
3
|
+
* (or by these groups/entities) must also pass one of these MFA methods. The machine-access plan's
|
|
4
|
+
* "admin requires OpenBao login MFA" is this resource.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ ENROL BEFORE YOU ENFORCE. A login the enforcement matches, by an entity with no secret for any
|
|
7
|
+
* listed method, FAILS (login_mfa.go validateLoginMFAInternal: "MFA secret … not present"). Enrol
|
|
8
|
+
* every admin (`identity/mfa/method/totp/admin-generate`, which RETURNS the secret — never through
|
|
9
|
+
* Alchemy) before the first deploy of this, or the admin lane locks itself out.
|
|
10
|
+
* ⚠️ IT MATCHES MOUNTS, GROUPS AND ENTITIES — NEVER ROLES. An enforcement on the `jwt/` accessor
|
|
11
|
+
* covers every role on that mount (login_mfa.go buildMFAEnforcementConfigList :932-1017). To put
|
|
12
|
+
* MFA on admin only, give admin its own auth mount, or target an identity group of admins.
|
|
13
|
+
* ⛔ THE DELETE IS REFUSED: IN 2.6.2 IT DOES NOT STICK. DeleteMFALoginEnforcementConfigByNameAndNamespace
|
|
14
|
+
* (login_mfa.go:1879-1902) removes the entry from memory and never from storage, so it RETURNS on the
|
|
15
|
+
* next unseal — tracked upstream as openbao/openbao#4030, open on 2026-09-21. Worse, a method
|
|
16
|
+
* deleted in between leaves the resurrected enforcement naming an id that no longer exists, and
|
|
17
|
+
* every login it matches then fails ("MFA method configuration not present", :871-877). A delete
|
|
18
|
+
* that reports success and quietly comes back is the lie this package refuses to tell. Under the
|
|
19
|
+
* default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
|
|
20
|
+
* live — which is what the server would do anyway.
|
|
21
|
+
*
|
|
22
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first (names are per
|
|
23
|
+
* namespace; the new one cannot collide). Under `retain` BOTH enforcements then apply — fail
|
|
24
|
+
* closed. Under `destroy` the old one's delete is refused, so the deploy stops after creating.
|
|
25
|
+
*/
|
|
26
|
+
import { Resource } from 'alchemy';
|
|
27
|
+
import { isResolved } from 'alchemy/Diff';
|
|
28
|
+
import * as Provider from 'alchemy/Provider';
|
|
29
|
+
import * as Effect from 'effect/Effect';
|
|
30
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
31
|
+
import type { BaoError } from './bao-status.ts';
|
|
32
|
+
import {
|
|
33
|
+
type BaoMfaLoginEnforcementAttributes,
|
|
34
|
+
type BaoMfaLoginEnforcementProps,
|
|
35
|
+
attributesOf,
|
|
36
|
+
canonicalFromProps,
|
|
37
|
+
matches,
|
|
38
|
+
problems,
|
|
39
|
+
writeBody,
|
|
40
|
+
} from './mfa-enforcement-form.ts';
|
|
41
|
+
import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
|
|
42
|
+
|
|
43
|
+
export type {
|
|
44
|
+
BaoMfaLoginEnforcementAttributes,
|
|
45
|
+
BaoMfaLoginEnforcementProps,
|
|
46
|
+
} from './mfa-enforcement-form.ts';
|
|
47
|
+
|
|
48
|
+
export interface BaoMfaLoginEnforcement extends Resource<
|
|
49
|
+
'Bao.MfaLoginEnforcement',
|
|
50
|
+
BaoMfaLoginEnforcementProps,
|
|
51
|
+
BaoMfaLoginEnforcementAttributes,
|
|
52
|
+
never,
|
|
53
|
+
HttpClient.HttpClient
|
|
54
|
+
> {}
|
|
55
|
+
|
|
56
|
+
export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaLoginEnforcement', {
|
|
57
|
+
defaultRemovalPolicy: 'retain',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
type Env = HttpClient.HttpClient;
|
|
61
|
+
|
|
62
|
+
const refuse = (name: string, message: string): Effect.Effect<never> =>
|
|
63
|
+
Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
|
|
64
|
+
|
|
65
|
+
/** Paths resolved to accessors, and what the declaration would be refused for. */
|
|
66
|
+
const resolveTargets = (props: BaoMfaLoginEnforcementProps) =>
|
|
67
|
+
Effect.map(authAccessors(props.authMethodPaths ?? []), ({ accessors, missing }) => ({
|
|
68
|
+
bad: problems(props, missing),
|
|
69
|
+
resolved: accessors,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const readLive = (
|
|
73
|
+
name: string,
|
|
74
|
+
): Effect.Effect<BaoMfaLoginEnforcementAttributes | undefined, BaoError, Env> =>
|
|
75
|
+
Effect.map(readEnforcement(name), (live) =>
|
|
76
|
+
live === undefined ? undefined : attributesOf(name, live),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
export const planEnforcement = (
|
|
80
|
+
props: BaoMfaLoginEnforcementProps,
|
|
81
|
+
): Effect.Effect<'noop' | 'update', BaoError, Env> =>
|
|
82
|
+
Effect.gen(function* () {
|
|
83
|
+
const { bad, resolved } = yield* resolveTargets(props);
|
|
84
|
+
if (bad.length > 0) return 'update';
|
|
85
|
+
const live = yield* readLive(props.name);
|
|
86
|
+
return live !== undefined && matches(live, props, resolved) ? 'noop' : 'update';
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** Write when live differs, then prove it by reading back. */
|
|
90
|
+
export const reconcileEnforcement = (
|
|
91
|
+
props: BaoMfaLoginEnforcementProps,
|
|
92
|
+
): Effect.Effect<BaoMfaLoginEnforcementAttributes, BaoError, Env> =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const { bad, resolved } = yield* resolveTargets(props);
|
|
95
|
+
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
96
|
+
const live = yield* readLive(props.name);
|
|
97
|
+
if (live === undefined || !matches(live, props, resolved)) {
|
|
98
|
+
yield* writeEnforcement(props.name, writeBody(canonicalFromProps(props, resolved)));
|
|
99
|
+
}
|
|
100
|
+
const after = yield* readLive(props.name);
|
|
101
|
+
if (after === undefined)
|
|
102
|
+
return yield* refuse(props.name, 'the write succeeded but it is absent.');
|
|
103
|
+
if (!matches(after, props, resolved)) {
|
|
104
|
+
return yield* refuse(props.name, 'the enforcement read back different.');
|
|
105
|
+
}
|
|
106
|
+
return after;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
export const BaoMfaLoginEnforcementProvider = () =>
|
|
110
|
+
Provider.effect(
|
|
111
|
+
BaoMfaLoginEnforcement,
|
|
112
|
+
Effect.succeed(
|
|
113
|
+
BaoMfaLoginEnforcement.Provider.of({
|
|
114
|
+
/** ⛔ The namespace's enforcement listing is not a list of things this owns. */
|
|
115
|
+
list: () => Effect.succeed([]),
|
|
116
|
+
|
|
117
|
+
read: Effect.fn(function* ({ olds }) {
|
|
118
|
+
return yield* readLive(olds.name);
|
|
119
|
+
}),
|
|
120
|
+
|
|
121
|
+
/** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
|
|
122
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
123
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
124
|
+
if (news.name !== output.name) return { action: 'replace' } as const;
|
|
125
|
+
return { action: yield* planEnforcement(news) } as const;
|
|
126
|
+
}),
|
|
127
|
+
|
|
128
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
129
|
+
return yield* reconcileEnforcement(news);
|
|
130
|
+
}),
|
|
131
|
+
|
|
132
|
+
/** ⛔ REFUSED — read the header: in 2.6.2 the delete does not survive a restart. */
|
|
133
|
+
delete: Effect.fn(function* ({ output }) {
|
|
134
|
+
return yield* refuse(
|
|
135
|
+
output.name,
|
|
136
|
+
'REFUSING to delete: OpenBao 2.6.2 drops a login enforcement from memory only, and it ' +
|
|
137
|
+
'returns on the next unseal (openbao/openbao#4030). Set this resource back to ' +
|
|
138
|
+
'retain to drop it from state; remove it for good only once the server is fixed.',
|
|
139
|
+
);
|
|
140
|
+
}),
|
|
141
|
+
}),
|
|
142
|
+
),
|
|
143
|
+
);
|