@homeflare/alchemy 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/fake-runner.d.ts +36 -0
- package/dist/launchd/fake-runner.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +19 -0
- package/dist/launchd/host-effect.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +24 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -0
- package/dist/launchd/host-file-lifecycle.d.ts +29 -0
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -0
- package/dist/launchd/host-file.d.ts +25 -0
- package/dist/launchd/host-file.d.ts.map +1 -0
- package/dist/launchd/host-lookup.d.ts +27 -0
- package/dist/launchd/host-lookup.d.ts.map +1 -0
- package/dist/launchd/index.d.ts +21 -0
- package/dist/launchd/index.d.ts.map +1 -0
- package/dist/launchd/index.js +969 -0
- package/dist/launchd/index.js.map +25 -0
- package/dist/launchd/job-form.d.ts +90 -0
- package/dist/launchd/job-form.d.ts.map +1 -0
- package/dist/launchd/job-lifecycle.d.ts +34 -0
- package/dist/launchd/job-lifecycle.d.ts.map +1 -0
- package/dist/launchd/job-preflight.d.ts +50 -0
- package/dist/launchd/job-preflight.d.ts.map +1 -0
- package/dist/launchd/job-validate.d.ts +25 -0
- package/dist/launchd/job-validate.d.ts.map +1 -0
- package/dist/launchd/job.d.ts +34 -0
- package/dist/launchd/job.d.ts.map +1 -0
- package/dist/launchd/launchctl.d.ts +62 -0
- package/dist/launchd/launchctl.d.ts.map +1 -0
- package/dist/launchd/local-runner.d.ts +7 -0
- package/dist/launchd/local-runner.d.ts.map +1 -0
- package/dist/launchd/plist.d.ts +41 -0
- package/dist/launchd/plist.d.ts.map +1 -0
- package/dist/launchd/providers.d.ts +15 -0
- package/dist/launchd/providers.d.ts.map +1 -0
- package/dist/launchd/runner.d.ts +91 -0
- package/dist/launchd/runner.d.ts.map +1 -0
- package/dist/launchd/secret-tripwire.d.ts +23 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -0
- package/dist/openbao/approle-login-form.d.ts +72 -0
- package/dist/openbao/approle-login-form.d.ts.map +1 -0
- package/dist/openbao/approle-login-result.d.ts +39 -0
- package/dist/openbao/approle-login-result.d.ts.map +1 -0
- package/dist/openbao/approle-login.d.ts +77 -0
- package/dist/openbao/approle-login.d.ts.map +1 -0
- package/dist/openbao/auth-method-form.d.ts +7 -0
- package/dist/openbao/auth-method-form.d.ts.map +1 -1
- package/dist/openbao/auth-method-reconcile.d.ts +15 -0
- package/dist/openbao/auth-method-reconcile.d.ts.map +1 -0
- package/dist/openbao/auth-method.d.ts +6 -0
- package/dist/openbao/auth-method.d.ts.map +1 -1
- package/dist/openbao/auth-role-form.d.ts +9 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -1
- package/dist/openbao/auth-role.d.ts.map +1 -1
- package/dist/openbao/auth-token-form.d.ts +48 -0
- package/dist/openbao/auth-token-form.d.ts.map +1 -0
- package/dist/openbao/bao-http.d.ts +2 -0
- package/dist/openbao/bao-http.d.ts.map +1 -1
- package/dist/openbao/bao-identity.d.ts +81 -0
- package/dist/openbao/bao-identity.d.ts.map +1 -0
- package/dist/openbao/bao-namespace.d.ts +20 -0
- package/dist/openbao/bao-namespace.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -1
- package/dist/openbao/host-approles.d.ts +62 -0
- package/dist/openbao/host-approles.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +26 -0
- package/dist/openbao/index.d.ts.map +1 -1
- package/dist/openbao/index.js +1889 -477
- package/dist/openbao/index.js.map +46 -17
- package/dist/openbao/jwt-config-form.d.ts +51 -0
- package/dist/openbao/jwt-config-form.d.ts.map +1 -0
- package/dist/openbao/jwt-config.d.ts +25 -0
- package/dist/openbao/jwt-config.d.ts.map +1 -0
- package/dist/openbao/jwt-role-form.d.ts +91 -0
- package/dist/openbao/jwt-role-form.d.ts.map +1 -0
- package/dist/openbao/jwt-role.d.ts +31 -0
- package/dist/openbao/jwt-role.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role-form.d.ts +68 -0
- package/dist/openbao/kubernetes-role-form.d.ts.map +1 -0
- package/dist/openbao/kubernetes-role.d.ts +25 -0
- package/dist/openbao/kubernetes-role.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement-form.d.ts +46 -0
- package/dist/openbao/mfa-enforcement-form.d.ts.map +1 -0
- package/dist/openbao/mfa-enforcement.d.ts +41 -0
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -0
- package/dist/openbao/mfa-totp-form.d.ts +52 -0
- package/dist/openbao/mfa-totp-form.d.ts.map +1 -0
- package/dist/openbao/mfa-totp.d.ts +33 -0
- package/dist/openbao/mfa-totp.d.ts.map +1 -0
- package/dist/openbao/mfa-wire.d.ts +63 -0
- package/dist/openbao/mfa-wire.d.ts.map +1 -0
- package/dist/openbao/mount-form.d.ts +7 -0
- package/dist/openbao/mount-form.d.ts.map +1 -1
- package/dist/openbao/mount-move.d.ts +49 -0
- package/dist/openbao/mount-move.d.ts.map +1 -0
- package/dist/openbao/mount-path.d.ts +18 -0
- package/dist/openbao/mount-path.d.ts.map +1 -0
- package/dist/openbao/mount-reconcile.d.ts +16 -0
- package/dist/openbao/mount-reconcile.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +9 -0
- package/dist/openbao/mount.d.ts.map +1 -1
- package/dist/openbao/pki-role-canonical.d.ts +46 -0
- package/dist/openbao/pki-role-canonical.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +38 -30
- package/dist/openbao/pki-role-form.d.ts.map +1 -1
- package/dist/openbao/pki-role.d.ts.map +1 -1
- package/dist/openbao/plugin-form.d.ts +60 -0
- package/dist/openbao/plugin-form.d.ts.map +1 -0
- package/dist/openbao/plugin-reconcile.d.ts +18 -0
- package/dist/openbao/plugin-reconcile.d.ts.map +1 -0
- package/dist/openbao/plugin-wire.d.ts +45 -0
- package/dist/openbao/plugin-wire.d.ts.map +1 -0
- package/dist/openbao/plugin.d.ts +31 -0
- package/dist/openbao/plugin.d.ts.map +1 -0
- package/dist/openbao/remount-wire.d.ts +44 -0
- package/dist/openbao/remount-wire.d.ts.map +1 -0
- package/dist/openbao/role-reconcile.d.ts +40 -0
- package/dist/openbao/role-reconcile.d.ts.map +1 -0
- package/dist/openbao/ssh-role-form.d.ts +6 -5
- package/dist/openbao/ssh-role-form.d.ts.map +1 -1
- package/dist/openbao/ssh-role.d.ts.map +1 -1
- package/docs/launchd.md +167 -0
- package/package.json +7 -2
- package/src/index.ts +1 -0
- package/src/launchd/fake-runner.ts +176 -0
- package/src/launchd/host-effect.ts +28 -0
- package/src/launchd/host-file-form.ts +71 -0
- package/src/launchd/host-file-lifecycle.test.ts +198 -0
- package/src/launchd/host-file-lifecycle.ts +164 -0
- package/src/launchd/host-file.ts +64 -0
- package/src/launchd/host-lookup.test.ts +92 -0
- package/src/launchd/host-lookup.ts +94 -0
- package/src/launchd/index.ts +26 -0
- package/src/launchd/job-form.test.ts +171 -0
- package/src/launchd/job-form.ts +180 -0
- package/src/launchd/job-identity.test.ts +147 -0
- package/src/launchd/job-lifecycle.test.ts +220 -0
- package/src/launchd/job-lifecycle.ts +194 -0
- package/src/launchd/job-preflight.ts +152 -0
- package/src/launchd/job-validate.ts +134 -0
- package/src/launchd/job.ts +96 -0
- package/src/launchd/launchctl.test.ts +181 -0
- package/src/launchd/launchctl.ts +188 -0
- package/src/launchd/local-runner.test.ts +125 -0
- package/src/launchd/local-runner.ts +143 -0
- package/src/launchd/plist.test.ts +186 -0
- package/src/launchd/plist.ts +156 -0
- package/src/launchd/providers.test.ts +222 -0
- package/src/launchd/providers.ts +21 -0
- package/src/launchd/runner.ts +95 -0
- package/src/launchd/secret-tripwire.test.ts +110 -0
- package/src/launchd/secret-tripwire.ts +81 -0
- package/src/openbao/README.md +181 -0
- package/src/openbao/REPLACE.md +87 -0
- package/src/openbao/approle-login-form.test.ts +64 -0
- package/src/openbao/approle-login-form.ts +153 -0
- package/src/openbao/approle-login-result.ts +96 -0
- package/src/openbao/approle-login.test.ts +200 -0
- package/src/openbao/approle-login.ts +156 -0
- package/src/openbao/auth-method-form.ts +7 -0
- package/src/openbao/auth-method-reconcile.ts +74 -0
- package/src/openbao/auth-method.ts +20 -41
- package/src/openbao/auth-role-form.test.ts +57 -0
- package/src/openbao/auth-role-form.ts +11 -0
- package/src/openbao/auth-role.ts +12 -0
- package/src/openbao/auth-token-form.ts +161 -0
- package/src/openbao/bao-http.ts +2 -0
- package/src/openbao/bao-identity.test.ts +131 -0
- package/src/openbao/bao-identity.ts +188 -0
- package/src/openbao/bao-namespace.ts +32 -0
- package/src/openbao/cloudflare-roles-expand.ts +2 -5
- package/src/openbao/host-approles.test.ts +121 -0
- package/src/openbao/host-approles.ts +126 -0
- package/src/openbao/index.ts +32 -0
- package/src/openbao/jwt-config-form.ts +161 -0
- package/src/openbao/jwt-config.ts +85 -0
- package/src/openbao/jwt-role-form.test.ts +130 -0
- package/src/openbao/jwt-role-form.ts +231 -0
- package/src/openbao/jwt-role.ts +102 -0
- package/src/openbao/kubernetes-role-form.test.ts +82 -0
- package/src/openbao/kubernetes-role-form.ts +149 -0
- package/src/openbao/kubernetes-role.ts +94 -0
- package/src/openbao/mfa-enforcement-form.ts +147 -0
- package/src/openbao/mfa-enforcement.ts +143 -0
- package/src/openbao/mfa-totp-form.ts +155 -0
- package/src/openbao/mfa-totp.ts +118 -0
- package/src/openbao/mfa-wire.ts +117 -0
- package/src/openbao/mfa.test.ts +200 -0
- package/src/openbao/mount-form.ts +9 -1
- package/src/openbao/mount-move.test.ts +164 -0
- package/src/openbao/mount-move.ts +113 -0
- package/src/openbao/mount-path.test.ts +33 -0
- package/src/openbao/mount-path.ts +31 -0
- package/src/openbao/mount-reconcile.ts +75 -0
- package/src/openbao/mount-wire.test.ts +13 -0
- package/src/openbao/mount.ts +26 -43
- package/src/openbao/pki-role-canonical.ts +163 -0
- package/src/openbao/pki-role-form.test.ts +112 -0
- package/src/openbao/pki-role-form.ts +76 -110
- package/src/openbao/pki-role.ts +12 -27
- package/src/openbao/plugin-form.test.ts +86 -0
- package/src/openbao/plugin-form.ts +164 -0
- package/src/openbao/plugin-reconcile.ts +92 -0
- package/src/openbao/plugin-wire.test.ts +146 -0
- package/src/openbao/plugin-wire.ts +65 -0
- package/src/openbao/plugin.ts +100 -0
- package/src/openbao/remount-wire.test.ts +87 -0
- package/src/openbao/remount-wire.ts +112 -0
- package/src/openbao/role-reconcile.test.ts +104 -0
- package/src/openbao/role-reconcile.ts +84 -0
- package/src/openbao/ssh-role-form.test.ts +105 -0
- package/src/openbao/ssh-role-form.ts +18 -16
- package/src/openbao/ssh-role.ts +3 -4
|
@@ -0,0 +1,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
|
+
);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The form half of Bao.MfaTotpMethod — a TOTP login-MFA method, identified by its `method_name`.
|
|
3
|
+
*
|
|
4
|
+
* ★ READ FROM openbao v2.6.2 vault/identity/store.go:349-427 (fields) and vault/identity/mfa.go
|
|
5
|
+
* parseTOTPConfig (:662-741): `issuer` is required; `algorithm` is SHA1|SHA256|SHA512; `digits`
|
|
6
|
+
* 6|8; `skew` 0|1; `period`, `key_size` above zero; `max_validation_attempts` 0 means the default
|
|
7
|
+
* of 5 (:35). EVERY field is `d.Get`, so a write resets whatever it omits — every one is sent.
|
|
8
|
+
* ★ THE READ (login_mfa.go mfaConfigToMap :775-829) answers `period` in seconds and `algorithm` as
|
|
9
|
+
* its name; `name`, `id` and `namespace_path` ride along.
|
|
10
|
+
* ⚠️ A CHANGED ALGORITHM, DIGITS OR PERIOD APPLIES TO NEW ENROLMENTS ONLY. Validation uses the
|
|
11
|
+
* parameters stored with each entity's own secret (login_mfa.go validateTOTP :1520-1545), so an
|
|
12
|
+
* enrolled admin keeps working — with the old parameters — until re-enrolled.
|
|
13
|
+
* ⛔ NO SECRET: the method holds no key. Each entity's TOTP secret is generated per entity by
|
|
14
|
+
* `.../totp/generate` or `admin-generate`, which RETURNS it; this resource calls neither.
|
|
15
|
+
*/
|
|
16
|
+
import { sha256 } from './digest.ts';
|
|
17
|
+
import { parseDuration, ttlSeconds } from './mount-form.ts';
|
|
18
|
+
|
|
19
|
+
export type BaoTotpAlgorithm = 'SHA1' | 'SHA256' | 'SHA512';
|
|
20
|
+
|
|
21
|
+
export interface BaoMfaTotpMethodProps {
|
|
22
|
+
/** `method_name` — the declarative key, unique per namespace. ⛔ A rename is refused (mfa-totp.ts). */
|
|
23
|
+
name: string;
|
|
24
|
+
/** The issuer an authenticator app shows, e.g. `vault-example`. */
|
|
25
|
+
issuer: string;
|
|
26
|
+
/** Code lifetime. Default `30s`. */
|
|
27
|
+
period?: string;
|
|
28
|
+
/** Secret size in bytes. Default 20. */
|
|
29
|
+
keySize?: number;
|
|
30
|
+
/** QR code size in pixels; `0` for none. Default 200. */
|
|
31
|
+
qrSize?: number;
|
|
32
|
+
/** Default `SHA1` — the one every authenticator app supports. */
|
|
33
|
+
algorithm?: BaoTotpAlgorithm;
|
|
34
|
+
/** Default 6. */
|
|
35
|
+
digits?: 6 | 8;
|
|
36
|
+
/** Periods of clock skew accepted. Default 1. */
|
|
37
|
+
skew?: 0 | 1;
|
|
38
|
+
/** Failed codes before the entity is locked out of this method. Default 5. */
|
|
39
|
+
maxValidationAttempts?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface BaoMfaTotpCanonical {
|
|
43
|
+
algorithm: string;
|
|
44
|
+
digits: number;
|
|
45
|
+
issuer: string;
|
|
46
|
+
keySize: number;
|
|
47
|
+
maxValidationAttempts: number;
|
|
48
|
+
periodSeconds: number;
|
|
49
|
+
qrSize: number;
|
|
50
|
+
skew: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface BaoMfaTotpMethodAttributes extends BaoMfaTotpCanonical {
|
|
54
|
+
/** The server-made UUID — what a Bao.MfaLoginEnforcement names in `mfaMethodIds`. */
|
|
55
|
+
methodId: string;
|
|
56
|
+
name: string;
|
|
57
|
+
/** `''` for root, `team-a/` below it. */
|
|
58
|
+
namespacePath: string;
|
|
59
|
+
digest: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const canonical = (input: BaoMfaTotpCanonical): BaoMfaTotpCanonical => ({
|
|
63
|
+
algorithm: input.algorithm,
|
|
64
|
+
digits: input.digits,
|
|
65
|
+
issuer: input.issuer,
|
|
66
|
+
keySize: input.keySize,
|
|
67
|
+
maxValidationAttempts: input.maxValidationAttempts,
|
|
68
|
+
periodSeconds: input.periodSeconds,
|
|
69
|
+
qrSize: input.qrSize,
|
|
70
|
+
skew: input.skew,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export const canonicalFromProps = (props: BaoMfaTotpMethodProps): BaoMfaTotpCanonical =>
|
|
74
|
+
canonical({
|
|
75
|
+
algorithm: props.algorithm ?? 'SHA1',
|
|
76
|
+
digits: props.digits ?? 6,
|
|
77
|
+
issuer: props.issuer,
|
|
78
|
+
keySize: props.keySize ?? 20,
|
|
79
|
+
maxValidationAttempts: props.maxValidationAttempts ?? 5,
|
|
80
|
+
periodSeconds: parseDuration(props.period ?? '30s') ?? -1,
|
|
81
|
+
qrSize: props.qrSize ?? 200,
|
|
82
|
+
skew: props.skew ?? 1,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const num = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : -1);
|
|
86
|
+
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
|
87
|
+
|
|
88
|
+
export const canonicalFromLive = (live: Record<string, unknown>): BaoMfaTotpCanonical =>
|
|
89
|
+
canonical({
|
|
90
|
+
algorithm: text(live['algorithm']),
|
|
91
|
+
digits: num(live['digits']),
|
|
92
|
+
issuer: text(live['issuer']),
|
|
93
|
+
keySize: num(live['key_size']),
|
|
94
|
+
maxValidationAttempts: num(live['max_validation_attempts']),
|
|
95
|
+
periodSeconds: ttlSeconds(live['period']) ?? -1,
|
|
96
|
+
qrSize: num(live['qr_size']),
|
|
97
|
+
skew: num(live['skew']),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
export const attributesOf = (
|
|
101
|
+
methodId: string,
|
|
102
|
+
live: Record<string, unknown>,
|
|
103
|
+
): BaoMfaTotpMethodAttributes => {
|
|
104
|
+
const form = canonicalFromLive(live);
|
|
105
|
+
return {
|
|
106
|
+
...form,
|
|
107
|
+
digest: sha256(JSON.stringify(form)),
|
|
108
|
+
methodId,
|
|
109
|
+
name: text(live['name']),
|
|
110
|
+
namespacePath: text(live['namespace_path']),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const matches = (attributes: BaoMfaTotpMethodAttributes, props: BaoMfaTotpMethodProps) =>
|
|
115
|
+
attributes.digest === sha256(JSON.stringify(canonicalFromProps(props)));
|
|
116
|
+
|
|
117
|
+
/** The upsert body — by `method_name`, never `method_id` (mfa-wire.ts). */
|
|
118
|
+
export const writeBody = (props: BaoMfaTotpMethodProps): Record<string, unknown> => {
|
|
119
|
+
const form = canonicalFromProps(props);
|
|
120
|
+
return {
|
|
121
|
+
algorithm: form.algorithm,
|
|
122
|
+
digits: form.digits,
|
|
123
|
+
issuer: form.issuer,
|
|
124
|
+
key_size: form.keySize,
|
|
125
|
+
max_validation_attempts: form.maxValidationAttempts,
|
|
126
|
+
method_name: props.name,
|
|
127
|
+
period: props.period ?? '30s',
|
|
128
|
+
qr_size: form.qrSize,
|
|
129
|
+
skew: form.skew,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The diff's refusal for a changed name, or undefined when the name is unchanged. Never a
|
|
135
|
+
* `replace`: the ⛔ on a rename in mfa-totp.ts says why.
|
|
136
|
+
*/
|
|
137
|
+
export const renameProblem = (from: string, to: string): string | undefined =>
|
|
138
|
+
from === to
|
|
139
|
+
? undefined
|
|
140
|
+
: `renaming from \`${from}\` would strand every enrolled secret. Declare the new method as a ` +
|
|
141
|
+
'second resource, list both ids on the enforcement, enrol, then remove the old one ' +
|
|
142
|
+
'(mfa-totp.ts).';
|
|
143
|
+
|
|
144
|
+
export const problems = (props: BaoMfaTotpMethodProps): readonly string[] => {
|
|
145
|
+
const found: string[] = [];
|
|
146
|
+
const form = canonicalFromProps(props);
|
|
147
|
+
if (props.name.trim() === '') found.push('name is empty; it is the only key a declaration has');
|
|
148
|
+
if (props.issuer.trim() === '') found.push('issuer is empty; the server requires one');
|
|
149
|
+
if (form.periodSeconds <= 0)
|
|
150
|
+
found.push(`period \`${props.period ?? ''}\` must be a duration above zero`);
|
|
151
|
+
if (form.keySize <= 0) found.push('keySize must be above zero');
|
|
152
|
+
if (form.maxValidationAttempts < 1)
|
|
153
|
+
found.push('maxValidationAttempts must be at least 1 (0 reads back as 5)');
|
|
154
|
+
return found;
|
|
155
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A TOTP login-MFA method — `identity/mfa/method/totp`, found by `method_name`. The method is
|
|
3
|
+
* policy only: issuer, period, digits. Its `methodId` is what a Bao.MfaLoginEnforcement names.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ A RENAME IS REFUSED, NOT REPLACED. A new name is a new method with a new id, and every entity's
|
|
6
|
+
* enrolled secret belongs to the OLD id (login_mfa.go validateTOTP reads `entity.MFASecrets[id]`).
|
|
7
|
+
* The enforcement would follow the new id in the same deploy, and every enrolled admin would then
|
|
8
|
+
* fail MFA — with nobody left able to log in and re-enrol them. The safe move is additive: declare
|
|
9
|
+
* the new method as a SECOND resource, list both ids on the enforcement (any one method passes,
|
|
10
|
+
* login_mfa.go:831-858), enrol everyone, then drop the old one.
|
|
11
|
+
* ⛔ DELETING THE METHOD DESTROYS EVERY ENROLLED SECRET FOR IT (mfa-wire.ts deleteTotp) — and with
|
|
12
|
+
* openbao/openbao#4030, a deleted enforcement can come back after a restart still naming this id,
|
|
13
|
+
* failing every login it matches. `retain` is the default for exactly that reason.
|
|
14
|
+
*
|
|
15
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): never `replace`. `name` changed → the plan FAILS (above); any
|
|
16
|
+
* other field is an in-place `update`, because the upsert keeps the id.
|
|
17
|
+
*/
|
|
18
|
+
import { Resource } from 'alchemy';
|
|
19
|
+
import { isResolved } from 'alchemy/Diff';
|
|
20
|
+
import * as Provider from 'alchemy/Provider';
|
|
21
|
+
import * as Effect from 'effect/Effect';
|
|
22
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
23
|
+
import type { BaoError } from './bao-status.ts';
|
|
24
|
+
import {
|
|
25
|
+
type BaoMfaTotpMethodAttributes,
|
|
26
|
+
type BaoMfaTotpMethodProps,
|
|
27
|
+
attributesOf,
|
|
28
|
+
matches,
|
|
29
|
+
problems,
|
|
30
|
+
renameProblem,
|
|
31
|
+
writeBody,
|
|
32
|
+
} from './mfa-totp-form.ts';
|
|
33
|
+
import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
|
|
34
|
+
|
|
35
|
+
export type {
|
|
36
|
+
BaoMfaTotpMethodAttributes,
|
|
37
|
+
BaoMfaTotpMethodProps,
|
|
38
|
+
BaoTotpAlgorithm,
|
|
39
|
+
} from './mfa-totp-form.ts';
|
|
40
|
+
|
|
41
|
+
export interface BaoMfaTotpMethod extends Resource<
|
|
42
|
+
'Bao.MfaTotpMethod',
|
|
43
|
+
BaoMfaTotpMethodProps,
|
|
44
|
+
BaoMfaTotpMethodAttributes,
|
|
45
|
+
never,
|
|
46
|
+
HttpClient.HttpClient
|
|
47
|
+
> {}
|
|
48
|
+
|
|
49
|
+
export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod', {
|
|
50
|
+
defaultRemovalPolicy: 'retain',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const refuse = (props: BaoMfaTotpMethodProps, message: string): Effect.Effect<never> =>
|
|
54
|
+
Effect.die(new Error(`Bao.MfaTotpMethod ${props.name}: ${message}`));
|
|
55
|
+
|
|
56
|
+
export const readTotp = (
|
|
57
|
+
props: BaoMfaTotpMethodProps,
|
|
58
|
+
): Effect.Effect<BaoMfaTotpMethodAttributes | undefined, BaoError, HttpClient.HttpClient> =>
|
|
59
|
+
Effect.map(findTotp(props.name), (found) =>
|
|
60
|
+
found === undefined ? undefined : attributesOf(found.id, found.live),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
export const planTotp = (
|
|
64
|
+
props: BaoMfaTotpMethodProps,
|
|
65
|
+
): Effect.Effect<'noop' | 'update', BaoError, HttpClient.HttpClient> =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
if (problems(props).length > 0) return 'update';
|
|
68
|
+
const live = yield* readTotp(props);
|
|
69
|
+
return live !== undefined && matches(live, props) ? 'noop' : 'update';
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/** Upsert by name when live differs, then prove it by reading back. */
|
|
73
|
+
export const reconcileTotp = (
|
|
74
|
+
props: BaoMfaTotpMethodProps,
|
|
75
|
+
): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
|
|
76
|
+
Effect.gen(function* () {
|
|
77
|
+
const bad = problems(props);
|
|
78
|
+
if (bad.length > 0) return yield* refuse(props, bad.join('; '));
|
|
79
|
+
const live = yield* readTotp(props);
|
|
80
|
+
if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
|
|
81
|
+
const after = yield* readTotp(props);
|
|
82
|
+
if (after === undefined)
|
|
83
|
+
return yield* refuse(props, 'the write succeeded but no method has that name.');
|
|
84
|
+
if (!matches(after, props)) return yield* refuse(props, 'the method read back different.');
|
|
85
|
+
return after;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const BaoMfaTotpMethodProvider = () =>
|
|
89
|
+
Provider.effect(
|
|
90
|
+
BaoMfaTotpMethod,
|
|
91
|
+
Effect.succeed(
|
|
92
|
+
BaoMfaTotpMethod.Provider.of({
|
|
93
|
+
/** ⛔ The namespace's method listing is not a list of things this owns. */
|
|
94
|
+
list: () => Effect.succeed([]),
|
|
95
|
+
|
|
96
|
+
read: Effect.fn(function* ({ olds }) {
|
|
97
|
+
return yield* readTotp(olds);
|
|
98
|
+
}),
|
|
99
|
+
|
|
100
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
101
|
+
if (output === undefined || !isResolved(news)) return undefined;
|
|
102
|
+
const rename = renameProblem(output.name, news.name);
|
|
103
|
+
if (rename !== undefined) return yield* refuse(news, rename);
|
|
104
|
+
return { action: yield* planTotp(news) } as const;
|
|
105
|
+
}),
|
|
106
|
+
|
|
107
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
108
|
+
return yield* reconcileTotp(news);
|
|
109
|
+
}),
|
|
110
|
+
|
|
111
|
+
/** ⛔ Read the header first. Idempotent: an unknown id deletes as success. */
|
|
112
|
+
delete: Effect.fn(function* ({ output }) {
|
|
113
|
+
yield* deleteTotp(output.methodId);
|
|
114
|
+
return undefined;
|
|
115
|
+
}),
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The OpenBao calls behind login MFA: TOTP methods under identity/mfa/method/totp, login
|
|
3
|
+
* enforcements under identity/mfa/login-enforcement/<name>, and the auth table that turns a mount
|
|
4
|
+
* path into the accessor an enforcement names.
|
|
5
|
+
*
|
|
6
|
+
* ★ OpenBao 2.6.2 SHIPS LOGIN MFA — confirmed in source before any of this was written:
|
|
7
|
+
* vault/identity/store.go:312-800 (mfaPaths) registers `mfa/method/totp`, `mfa/login-enforcement/
|
|
8
|
+
* <name>` and their listings on the identity backend; vault/identity/mfa.go holds the handlers
|
|
9
|
+
* and vault/login_mfa.go the storage.
|
|
10
|
+
* ★ A TOTP METHOD IS FOUND BY NAME, NOT ID. The id is a server-made UUID; `method_name` is the only
|
|
11
|
+
* thing a declaration can know. An update without `method_id` UPSERTS by name within the request's
|
|
12
|
+
* namespace (identity/mfa.go:159-230, MemDBMFAConfigByName keys on namespace + name), so the same
|
|
13
|
+
* write both creates and updates. The listing (`key_info`, one entry per id, :63-69) answers the
|
|
14
|
+
* methods of this namespace AND its parents (login_mfa.go:652-713), so the match also requires
|
|
15
|
+
* this namespace's `namespace_path`.
|
|
16
|
+
* ⛔ THE NAME INDEX IGNORES THE TYPE, SO THIS LISTS EVERY TYPE. login_mfa.go's `name` index is
|
|
17
|
+
* (namespace_id, name) across all methods (:1642-1655, :1818), and handleMFAMethodUpdateCommon
|
|
18
|
+
* then sets `mConfig.Type = "totp"` on whatever it found (identity/mfa.go:228). A TOTP write under
|
|
19
|
+
* the name of a Duo or Okta method would silently CONVERT that method — and every enforcement
|
|
20
|
+
* naming its id would start demanding a TOTP code nobody is enrolled for. The per-type listing
|
|
21
|
+
* (`.../method/totp?list=true`) cannot see the clash, so this reads the global one
|
|
22
|
+
* (`identity/mfa/method?list=true`, store.go:335) and refuses a same-named method of
|
|
23
|
+
* another type. ⚠️ The deploying token needs `list` on `identity/mfa/method`.
|
|
24
|
+
* ⚠️ AN UPDATE BY A STALE `method_id` IS A SILENT NO-OP: the handler returns nil for an unknown id
|
|
25
|
+
* (identity/mfa.go:172-183), which HTTP answers 204. So this never writes by id.
|
|
26
|
+
*/
|
|
27
|
+
import * as Effect from 'effect/Effect';
|
|
28
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
29
|
+
import { BaoEnv, baoCall, baoDelete, baoRead } from './bao-http.ts';
|
|
30
|
+
import { envNamespace, namespacePath } from './bao-namespace.ts';
|
|
31
|
+
import type { BaoError } from './bao-status.ts';
|
|
32
|
+
import { mountPath } from './mount-form.ts';
|
|
33
|
+
|
|
34
|
+
export const TOTP_PATH = 'identity/mfa/method/totp';
|
|
35
|
+
/** Every MFA method of every type — the only listing that can see a name clash across types. */
|
|
36
|
+
export const METHODS_PATH = 'identity/mfa/method';
|
|
37
|
+
export const enforcementPath = (name: string): string => `identity/mfa/login-enforcement/${name}`;
|
|
38
|
+
|
|
39
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
40
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
41
|
+
? (value as Record<string, unknown>)
|
|
42
|
+
: undefined;
|
|
43
|
+
|
|
44
|
+
/** This call's namespace as OpenBao writes `namespace_path`: `''` or `team-a/`. */
|
|
45
|
+
export const ownNamespacePath: Effect.Effect<string> = Effect.gen(function* () {
|
|
46
|
+
const env = yield* BaoEnv;
|
|
47
|
+
return namespacePath(envNamespace(env));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type TotpEntry = { readonly id: string; readonly live: Record<string, unknown> };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The TOTP method named `name` in this namespace, or undefined. Dies when the name belongs to a
|
|
54
|
+
* method of ANOTHER type here — writing would convert it (the ⛔ above).
|
|
55
|
+
*/
|
|
56
|
+
export const findTotp = (
|
|
57
|
+
name: string,
|
|
58
|
+
): Effect.Effect<TotpEntry | undefined, BaoError, HttpClient.HttpClient> =>
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const listing = yield* baoRead(`${METHODS_PATH}?list=true`);
|
|
61
|
+
const info = record(listing?.['key_info']) ?? {};
|
|
62
|
+
const own = yield* ownNamespacePath;
|
|
63
|
+
for (const [id, raw] of Object.entries(info)) {
|
|
64
|
+
const live = record(raw);
|
|
65
|
+
if (live === undefined || live['name'] !== name || live['namespace_path'] !== own) continue;
|
|
66
|
+
if (live['type'] !== 'totp') {
|
|
67
|
+
return yield* Effect.die(
|
|
68
|
+
new Error(
|
|
69
|
+
`Bao.MfaTotpMethod ${name}: that name belongs to a ${String(live['type'])} method ` +
|
|
70
|
+
`(${id}) in this namespace, and a TOTP write would convert it. Pick another name.`,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return { id, live };
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Create-or-update by `method_name` — never by id (the ⚠️ above). */
|
|
80
|
+
export const writeTotp = (body: Readonly<Record<string, unknown>>) =>
|
|
81
|
+
Effect.asVoid(baoCall('write', 'POST', TOTP_PATH, body));
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* ⛔ DESTROYS EVERY ENTITY'S ENROLLED SECRET FOR THIS METHOD (login_mfa.go:1965-1973 clears the TOTP
|
|
85
|
+
* keys under the method id). OpenBao refuses it while an enforcement in memory still names the id
|
|
86
|
+
* (:1914-1924) — a refusal this passes on.
|
|
87
|
+
*/
|
|
88
|
+
export const deleteTotp = (id: string) => baoDelete(`${TOTP_PATH}/${id}`);
|
|
89
|
+
|
|
90
|
+
export const readEnforcement = (name: string) => baoRead(enforcementPath(name));
|
|
91
|
+
|
|
92
|
+
export const writeEnforcement = (name: string, body: Readonly<Record<string, unknown>>) =>
|
|
93
|
+
Effect.asVoid(baoCall('write', 'POST', enforcementPath(name), body));
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Accessors for auth mount paths, from `GET sys/auth` (keys are `<path>/`). A path with no mount
|
|
97
|
+
* comes back in `missing`, never as a guess.
|
|
98
|
+
*/
|
|
99
|
+
export const authAccessors = (
|
|
100
|
+
paths: readonly string[],
|
|
101
|
+
): Effect.Effect<
|
|
102
|
+
{ readonly accessors: readonly string[]; readonly missing: readonly string[] },
|
|
103
|
+
BaoError,
|
|
104
|
+
HttpClient.HttpClient
|
|
105
|
+
> =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
if (paths.length === 0) return { accessors: [], missing: [] };
|
|
108
|
+
const table = (yield* baoRead('sys/auth')) ?? {};
|
|
109
|
+
const accessors: string[] = [];
|
|
110
|
+
const missing: string[] = [];
|
|
111
|
+
for (const path of paths) {
|
|
112
|
+
const accessor = record(table[`${mountPath(path)}/`])?.['accessor'];
|
|
113
|
+
if (typeof accessor === 'string' && accessor !== '') accessors.push(accessor);
|
|
114
|
+
else missing.push(mountPath(path));
|
|
115
|
+
}
|
|
116
|
+
return { accessors, missing };
|
|
117
|
+
});
|