@homeflare/alchemy 0.7.0 → 0.8.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 -37
- 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 +71 -13
- package/dist/caddy/index.js.map +9 -9
- package/dist/caddy/with-file.d.ts +7 -3
- package/dist/caddy/with-file.d.ts.map +1 -1
- package/dist/launchd/fake-sudo.d.ts +50 -0
- package/dist/launchd/fake-sudo.d.ts.map +1 -0
- package/dist/launchd/host-effect.d.ts +18 -9
- package/dist/launchd/host-effect.d.ts.map +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.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 +342 -8
- package/dist/launchd/index.js.map +15 -11
- 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/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 +7 -5
- package/dist/launchd/runner.d.ts.map +1 -1
- 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 +56 -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-stage.d.ts +7 -0
- package/dist/launchd/sudo-stage.d.ts.map +1 -0
- 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-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 +49 -0
- package/dist/openbao/fake-families.d.ts.map +1 -0
- package/dist/openbao/host-approles.d.ts.map +1 -1
- package/dist/openbao/index.js +359 -280
- package/dist/openbao/index.js.map +21 -19
- 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 +6 -3
- package/dist/openbao/mfa-enforcement.d.ts.map +1 -1
- package/dist/openbao/mfa-totp.d.ts.map +1 -1
- package/dist/openbao/pki-role.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/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/docs/caddy-admin.md +24 -0
- package/docs/caddy.md +73 -54
- package/docs/launchd-sudo.md +122 -0
- package/docs/launchd.md +7 -4
- package/docs/peers.md +39 -0
- package/package.json +1 -1
- 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 -11
- 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/fake-sudo.ts +136 -0
- package/src/launchd/host-effect.ts +33 -1
- package/src/launchd/host-file-form.ts +10 -2
- package/src/launchd/host-file-lifecycle.ts +2 -1
- package/src/launchd/index.ts +2 -0
- package/src/launchd/job-preflight.ts +3 -2
- package/src/launchd/job-validate.ts +1 -1
- 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 +7 -5
- package/src/launchd/sudo-allowlist.test.ts +191 -0
- package/src/launchd/sudo-allowlist.ts +213 -0
- package/src/launchd/sudo-guard.test.ts +100 -0
- package/src/launchd/sudo-guard.ts +151 -0
- package/src/launchd/sudo-lifecycle.test.ts +172 -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 +235 -0
- package/src/launchd/sudo-stage.ts +28 -0
- package/src/openbao/README.md +8 -2
- package/src/openbao/REPLACE.md +65 -37
- 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 +35 -14
- package/src/openbao/cloudflare-role.ts +6 -14
- package/src/openbao/fake-engines-roles.ts +114 -0
- package/src/openbao/fake-engines.ts +18 -11
- package/src/openbao/fake-families.ts +182 -0
- package/src/openbao/host-approles.test.ts +15 -2
- package/src/openbao/host-approles.ts +19 -4
- package/src/openbao/jwt-config.ts +26 -5
- package/src/openbao/jwt-role.ts +24 -8
- package/src/openbao/kubernetes-role.ts +20 -8
- package/src/openbao/mfa-enforcement.ts +23 -8
- package/src/openbao/mfa-totp.ts +21 -8
- package/src/openbao/pki-role.ts +19 -7
- package/src/openbao/plugin.ts +41 -15
- package/src/openbao/policy.ts +7 -7
- package/src/openbao/proxmox-role.ts +7 -16
- package/src/openbao/rename-adoption.test.ts +82 -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/ssh-role-erase.ts +50 -0
- package/src/openbao/ssh-role.ts +16 -54
package/src/openbao/auth-role.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* are credential issuance, not configuration, and are OUT OF SCOPE. This declares the
|
|
6
6
|
* role shape an approle login inherits — policy names and TTL strings only.
|
|
7
7
|
*
|
|
8
|
-
* ★ `defaultRemovalPolicy: 'retain'` — deleting a role
|
|
9
|
-
*
|
|
8
|
+
* ★ `defaultRemovalPolicy: 'retain'` — deleting a role locks out every host that logs in with it:
|
|
9
|
+
* its secret_ids go with it (the ⚠️ on `delete` below). Opt in with
|
|
10
|
+
* `.pipe(RemovalPolicy.destroy())`; see resource.ts in house/proxmox.
|
|
10
11
|
*/
|
|
11
12
|
import { Resource } from 'alchemy';
|
|
12
13
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -17,12 +18,12 @@ import {
|
|
|
17
18
|
type BaoAuthRoleAttributes,
|
|
18
19
|
type BaoAuthRoleProps,
|
|
19
20
|
attributesOf,
|
|
20
|
-
isRename,
|
|
21
21
|
matches,
|
|
22
22
|
readPath,
|
|
23
23
|
writeBody,
|
|
24
24
|
} from './auth-role-form.ts';
|
|
25
25
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
26
|
+
import { foldName, guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
|
|
26
27
|
|
|
27
28
|
export type { BaoAuthRoleAttributes, BaoAuthRoleProps };
|
|
28
29
|
|
|
@@ -38,6 +39,14 @@ export const BaoAuthRole = Resource<BaoAuthRole>('Bao.AuthRole', {
|
|
|
38
39
|
defaultRemovalPolicy: 'retain',
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
|
|
44
|
+
* v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key,
|
|
45
|
+
* :1537), so `Host` → `host` is the SAME role: a `replace` there would write it, then — under
|
|
46
|
+
* `destroy` — delete the old generation, which is that same role.
|
|
47
|
+
*/
|
|
48
|
+
const IDENTITY = nameIdentity<BaoAuthRoleAttributes>('Bao.AuthRole', readPath, foldName);
|
|
49
|
+
|
|
41
50
|
const readRole = (props: BaoAuthRoleProps) =>
|
|
42
51
|
Effect.gen(function* () {
|
|
43
52
|
const live = yield* baoRead(readPath(props.name));
|
|
@@ -65,26 +74,31 @@ export const BaoAuthRoleProvider = () =>
|
|
|
65
74
|
* ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. A role edited in the OpenBao
|
|
66
75
|
* UI is exactly the drift the check-* gates exist to catch.
|
|
67
76
|
*/
|
|
68
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
69
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
77
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
70
78
|
/**
|
|
71
|
-
* ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace
|
|
72
|
-
* Until 2026-09-21 a new `name`
|
|
73
|
-
*
|
|
74
|
-
*
|
|
79
|
+
* ⛔ A RENAMED ROLE IS A NEW ROLE — NEW role_id, NEW secret_ids — SO IT IS A `replace`,
|
|
80
|
+
* judged before `isResolved(news)` (rename-identity.ts). Until 2026-09-21 a new `name`
|
|
81
|
+
* read nothing, planned `update` and wrote the new role, leaving the old one live
|
|
82
|
+
* under no state record: its secret_ids kept logging in for their whole TTL, and no
|
|
83
|
+
* plan would ever mention it again (REPLACE.md). ⛔ A rename onto a role that exists
|
|
84
|
+
* fails the plan.
|
|
75
85
|
* ⚠️ UNDER THE DEFAULT `retain` THE OLD ROLE STILL STAYS LIVE — retain keeps the old
|
|
76
86
|
* generation of a replace (Apply.ts:2164-2173). For per-host roles (host-approles.ts),
|
|
77
87
|
* opt into `RemovalPolicy.destroy()` or destroy the old role's accessors by hand.
|
|
78
88
|
*/
|
|
79
|
-
|
|
80
|
-
if (
|
|
89
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
90
|
+
if (output === undefined) return undefined;
|
|
91
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
92
|
+
if (!isResolved(news)) return undefined;
|
|
81
93
|
const live = yield* readRole(news);
|
|
82
94
|
if (live === undefined) return { action: 'update' } as const;
|
|
83
95
|
if (matches(live, news)) return { action: 'noop' } as const;
|
|
84
96
|
return { action: 'update' } as const;
|
|
85
97
|
}),
|
|
86
98
|
|
|
87
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
99
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
100
|
+
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
101
|
+
yield* guardRename(IDENTITY, news, output);
|
|
88
102
|
const live = yield* readRole(news);
|
|
89
103
|
if (live === undefined || !matches(live, news)) {
|
|
90
104
|
yield* baoWrite('PUT', readPath(news.name), writeBody(news));
|
|
@@ -101,8 +115,15 @@ export const BaoAuthRoleProvider = () =>
|
|
|
101
115
|
}),
|
|
102
116
|
|
|
103
117
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
118
|
+
* ⚠️ DELETING A ROLE DOES NOT REVOKE THE TOKENS IT ISSUED. openbao v2.6.2 pathRoleDelete
|
|
119
|
+
* (builtin/credential/approle/path_role.go:1858-1902) deletes every secret_id
|
|
120
|
+
* (flushRoleSecrets, validation.go:420-446), the role_id mapping and the role, so no new
|
|
121
|
+
* login succeeds, and it revokes nothing. A token already issued lives to its TTL; only
|
|
122
|
+
* its renewal fails, because pathLoginRenew (path_login.go:414-438) refuses once the role
|
|
123
|
+
* is gone. So this is not a containment action: to contain a leaked token, revoke it or
|
|
124
|
+
* its accessor. (Corrected 2026-09-21: this said the delete revoked every token.)
|
|
125
|
+
* Idempotent as Alchemy requires — already gone is success — but retain is the default,
|
|
126
|
+
* because every host logging in with the role is locked out at the delete.
|
|
106
127
|
*/
|
|
107
128
|
delete: Effect.fn(function* ({ output }) {
|
|
108
129
|
yield* baoDelete(readPath(output.name));
|
|
@@ -38,13 +38,7 @@ import {
|
|
|
38
38
|
readCloudflareRole,
|
|
39
39
|
writeCloudflareRole,
|
|
40
40
|
} from './cloudflare-role-wire.ts';
|
|
41
|
-
import {
|
|
42
|
-
declaredRolePath,
|
|
43
|
-
isMoved,
|
|
44
|
-
judgeMove,
|
|
45
|
-
refuseMovedUpdate,
|
|
46
|
-
triedRolePath,
|
|
47
|
-
} from './rename.ts';
|
|
41
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
48
42
|
|
|
49
43
|
export type { BaoCloudflareRoleAttributes, BaoCloudflareRoleProps };
|
|
50
44
|
|
|
@@ -66,6 +60,9 @@ export const BaoCloudflareRole = Resource<BaoCloudflareRole>('Bao.CloudflareRole
|
|
|
66
60
|
defaultRemovalPolicy: 'retain',
|
|
67
61
|
});
|
|
68
62
|
|
|
63
|
+
/** Exact: a trailing `/` on the mount is the same path (`rolePath` trims it). */
|
|
64
|
+
const IDENTITY = roleIdentity<BaoCloudflareRoleAttributes>('Bao.CloudflareRole', rolePath);
|
|
65
|
+
|
|
69
66
|
export const BaoCloudflareRoleProvider = () =>
|
|
70
67
|
Provider.effect(
|
|
71
68
|
BaoCloudflareRole,
|
|
@@ -131,9 +128,7 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
131
128
|
* and the old id leaves the stack as an orphan delete, which `retain` keeps live.
|
|
132
129
|
*/
|
|
133
130
|
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
134
|
-
const
|
|
135
|
-
const declared = declaredRolePath(news, rolePath);
|
|
136
|
-
const move = yield* judgeMove('Bao.CloudflareRole', tried, declared, (path) => path);
|
|
131
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
137
132
|
if (output === undefined) return undefined;
|
|
138
133
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
139
134
|
if (!isResolved(news)) return undefined;
|
|
@@ -153,10 +148,7 @@ export const BaoCloudflareRoleProvider = () =>
|
|
|
153
148
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
154
149
|
const path = rolePath(news.mount, news.name);
|
|
155
150
|
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
156
|
-
|
|
157
|
-
if (isMoved(before, path) === true) {
|
|
158
|
-
return yield* refuseMovedUpdate('Bao.CloudflareRole', before, path);
|
|
159
|
-
}
|
|
151
|
+
yield* guardRename(IDENTITY, news, output);
|
|
160
152
|
const refusal = refusalOf(news);
|
|
161
153
|
if (refusal !== undefined) {
|
|
162
154
|
return yield* Effect.die(new Error(`Bao.CloudflareRole ${path}: ${refusal}.`));
|
|
@@ -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,182 @@
|
|
|
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 { Seen } from './fake-bao.ts';
|
|
17
|
+
import { type Estate, fakeEstate } from './fake-engines-roles.ts';
|
|
18
|
+
import { type FakeStack, type StackBody, 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
|
+
/** One stack over a fresh estate: every family's provider, and every call the fake saw. */
|
|
42
|
+
export const withEstate = (
|
|
43
|
+
body: (stack: FakeStack, estate: Estate, seen: Seen[]) => Promise<void>,
|
|
44
|
+
): Promise<void> => {
|
|
45
|
+
const estate = fakeEstate();
|
|
46
|
+
return withFakeStack(familyProviders, estate, (stack, bao) => body(stack, estate, bao.seen));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The row for one family, by name. */
|
|
50
|
+
export const rowOf = (family: string): Family => {
|
|
51
|
+
const row = FAMILIES.find((each) => each.family === family);
|
|
52
|
+
if (row === undefined) throw new Error(`no fake-families row for ${family}`);
|
|
53
|
+
return row;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type Text = string | Output.Output<string>;
|
|
57
|
+
type Removal = typeof RemovalPolicy.destroy;
|
|
58
|
+
|
|
59
|
+
export interface Family {
|
|
60
|
+
readonly family: string;
|
|
61
|
+
/** One resource, logical id `id`, whose identity is `name`. */
|
|
62
|
+
readonly declare: (id: string, name: Text, knob: Text, removal?: Removal) => StackBody;
|
|
63
|
+
/** Whether the fake holds the object `name` names. */
|
|
64
|
+
readonly has: (estate: Estate, name: string) => boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const role = (path: (name: string) => string) => (estate: Estate, name: string) =>
|
|
68
|
+
estate.roles.live.has(path(name));
|
|
69
|
+
|
|
70
|
+
const APPROLE = { secretIdTtl: '24h', tokenMaxTtl: '1h', tokenPolicies: ['default'] } as const;
|
|
71
|
+
const SHA = 'a'.repeat(64);
|
|
72
|
+
|
|
73
|
+
export const FAMILIES: readonly Family[] = [
|
|
74
|
+
{
|
|
75
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
76
|
+
BaoAuthRole(id, { ...APPROLE, name, tokenTtl: knob }).pipe(removal()),
|
|
77
|
+
family: 'Bao.AuthRole',
|
|
78
|
+
has: role((name) => `auth/approle/role/${name}`),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
82
|
+
BaoPkiRole(id, { allowedDomains: ['example.com'], maxTtl: '8760h', name, ttl: knob }).pipe(
|
|
83
|
+
removal(),
|
|
84
|
+
),
|
|
85
|
+
family: 'Bao.PkiRole',
|
|
86
|
+
has: role((name) => `pki/roles/${name}`),
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
90
|
+
BaoJwtRole(id, {
|
|
91
|
+
boundAudiences: ['https://example.com'],
|
|
92
|
+
name,
|
|
93
|
+
roleType: 'jwt',
|
|
94
|
+
tokenPolicies: ['ci'],
|
|
95
|
+
tokenTtl: knob,
|
|
96
|
+
userClaim: 'sub',
|
|
97
|
+
}).pipe(removal()),
|
|
98
|
+
family: 'Bao.JwtRole',
|
|
99
|
+
has: role((name) => `auth/jwt/role/${name}`),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
103
|
+
BaoKubernetesRole(id, {
|
|
104
|
+
aliasNameSource: 'serviceaccount_uid',
|
|
105
|
+
boundServiceAccountNames: ['app'],
|
|
106
|
+
boundServiceAccountNamespaces: ['apps'],
|
|
107
|
+
name,
|
|
108
|
+
tokenPolicies: ['app'],
|
|
109
|
+
tokenTtl: knob,
|
|
110
|
+
}).pipe(removal()),
|
|
111
|
+
family: 'Bao.KubernetesRole',
|
|
112
|
+
has: role((name) => `auth/kubernetes/role/${name}`),
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
declare: (id, mount, knob, removal = RemovalPolicy.retain) =>
|
|
116
|
+
BaoJwtAuthConfig(id, {
|
|
117
|
+
boundIssuer: knob,
|
|
118
|
+
jwksUrl: 'https://issuer.example.com/keys',
|
|
119
|
+
mount,
|
|
120
|
+
}).pipe(removal()),
|
|
121
|
+
family: 'Bao.JwtAuthConfig',
|
|
122
|
+
has: role((mount) => `auth/${mount}/config`),
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
126
|
+
BaoMfaTotpMethod(id, { issuer: knob, name }).pipe(removal()),
|
|
127
|
+
family: 'Bao.MfaTotpMethod',
|
|
128
|
+
has: (estate, name) => [...estate.totp.live.values()].some((each) => each['name'] === name),
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
132
|
+
BaoMfaLoginEnforcement(id, {
|
|
133
|
+
authMethodTypes: ['jwt'],
|
|
134
|
+
mfaMethodIds: [knob],
|
|
135
|
+
name,
|
|
136
|
+
}).pipe(removal()),
|
|
137
|
+
family: 'Bao.MfaLoginEnforcement',
|
|
138
|
+
has: role((name) => `identity/mfa/login-enforcement/${name}`),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
142
|
+
BaoSshRole(id, {
|
|
143
|
+
allowUserCertificates: true,
|
|
144
|
+
allowedUsers: ['ops'],
|
|
145
|
+
maxTtl: '8h',
|
|
146
|
+
name,
|
|
147
|
+
ttl: knob,
|
|
148
|
+
}).pipe(removal()),
|
|
149
|
+
family: 'Bao.SshRole',
|
|
150
|
+
has: role((name) => `ssh/roles/${name}`),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
declare: (id, name, knob, removal = RemovalPolicy.retain) =>
|
|
154
|
+
BaoPlugin(id, {
|
|
155
|
+
args: [knob],
|
|
156
|
+
command: 'plugin-example',
|
|
157
|
+
name,
|
|
158
|
+
sha256: SHA,
|
|
159
|
+
type: 'secret',
|
|
160
|
+
version: 'v1.0.0',
|
|
161
|
+
}).pipe(removal()),
|
|
162
|
+
family: 'Bao.Plugin',
|
|
163
|
+
has: (estate, name) =>
|
|
164
|
+
estate.plugins.live.has(`sys/plugins/catalog/secret/${name}?version=v1.0.0`),
|
|
165
|
+
},
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Two of one family, `X` and `Y`, under destroy: the shape a swap or a shift needs. Their knobs
|
|
170
|
+
* differ, so a generation that lands on the other's name has to write over it.
|
|
171
|
+
*/
|
|
172
|
+
export const pairOf =
|
|
173
|
+
(row: Family) =>
|
|
174
|
+
(x: string, y: string): StackBody =>
|
|
175
|
+
Effect.gen(function* () {
|
|
176
|
+
yield* row.declare('X', x, '15m', RemovalPolicy.destroy);
|
|
177
|
+
yield* row.declare('Y', y, '30m', RemovalPolicy.destroy);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/** An AppRole whose `tokenTtl` attribute is the Output the other rows are fed. */
|
|
181
|
+
export const upstream = (ttl: string) =>
|
|
182
|
+
BaoAuthRole('Upstream', { ...APPROLE, name: 'upstream', tokenTtl: ttl });
|
|
@@ -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,6 +10,11 @@
|
|
|
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';
|
|
@@ -22,11 +27,12 @@ import {
|
|
|
22
27
|
attributesOf,
|
|
23
28
|
configPath,
|
|
24
29
|
matches,
|
|
25
|
-
mountOf,
|
|
26
30
|
problems,
|
|
27
31
|
wouldErase,
|
|
28
32
|
writeBody,
|
|
29
33
|
} from './jwt-config-form.ts';
|
|
34
|
+
import { declaredOr } from './rename.ts';
|
|
35
|
+
import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
|
|
30
36
|
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
31
37
|
|
|
32
38
|
export type { BaoJwtAuthConfigAttributes, BaoJwtAuthConfigProps } from './jwt-config-form.ts';
|
|
@@ -43,6 +49,16 @@ export const BaoJwtAuthConfig = Resource<BaoJwtAuthConfig>('Bao.JwtAuthConfig',
|
|
|
43
49
|
defaultRemovalPolicy: 'retain',
|
|
44
50
|
});
|
|
45
51
|
|
|
52
|
+
/** The identity is the mount alone; a trailing `/` is the same mount (`configPath` trims it). */
|
|
53
|
+
const IDENTITY: Identity<BaoJwtAuthConfigAttributes> = {
|
|
54
|
+
declared: (props) => {
|
|
55
|
+
const mount = declaredOr(props, 'mount', 'jwt');
|
|
56
|
+
return mount === undefined ? undefined : configPath({ mount });
|
|
57
|
+
},
|
|
58
|
+
family: 'Bao.JwtAuthConfig',
|
|
59
|
+
recorded: (output) => configPath(output),
|
|
60
|
+
};
|
|
61
|
+
|
|
46
62
|
export const jwtConfigSpec = (
|
|
47
63
|
props: BaoJwtAuthConfigProps,
|
|
48
64
|
): RoleSpec<BaoJwtAuthConfigAttributes> => ({
|
|
@@ -67,14 +83,19 @@ export const BaoJwtAuthConfigProvider = () =>
|
|
|
67
83
|
return found?.attributes;
|
|
68
84
|
}),
|
|
69
85
|
|
|
70
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
71
|
-
|
|
72
|
-
|
|
86
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
87
|
+
// ⛔ The mount first, before isResolved; onto a mount with a config fails the plan.
|
|
88
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
89
|
+
if (output === undefined) return undefined;
|
|
90
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
91
|
+
if (!isResolved(news)) return undefined;
|
|
73
92
|
return { action: yield* planRole(jwtConfigSpec(news)) } as const;
|
|
74
93
|
}),
|
|
75
94
|
|
|
76
95
|
/** ⛔ Refuses, before writing, a live config that carries an OIDC client (wouldErase). */
|
|
77
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
96
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
97
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
98
|
+
yield* guardRename(IDENTITY, news, output);
|
|
78
99
|
return yield* reconcileRole(jwtConfigSpec(news));
|
|
79
100
|
}),
|
|
80
101
|
|