@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/jwt-role.ts
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* ★ REPLACE SEMANTICS (REPLACE.md): `mount` or `name` changed → `replace`, create-first. The new
|
|
16
16
|
* path cannot collide with the old, so no `deleteFirst`. Under the default `retain` the old role
|
|
17
|
-
* stays live, and still admits logins, until removed by hand.
|
|
17
|
+
* stays live, and still admits logins, until removed by hand. ⛔ A move onto a role that already
|
|
18
|
+
* exists fails the plan (rename-identity.ts).
|
|
18
19
|
* ★ `defaultRemovalPolicy: 'retain'`, like every Bao.* family.
|
|
19
20
|
*/
|
|
20
21
|
import { Resource } from 'alchemy';
|
|
@@ -28,11 +29,11 @@ import {
|
|
|
28
29
|
type BaoJwtRoleProps,
|
|
29
30
|
attributesOf,
|
|
30
31
|
matches,
|
|
31
|
-
mountOf,
|
|
32
32
|
problems,
|
|
33
33
|
rolePath,
|
|
34
34
|
writeBody,
|
|
35
35
|
} from './jwt-role-form.ts';
|
|
36
|
+
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
36
37
|
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
37
38
|
|
|
38
39
|
export type {
|
|
@@ -54,6 +55,18 @@ export const BaoJwtRole = Resource<BaoJwtRole>('Bao.JwtRole', {
|
|
|
54
55
|
defaultRemovalPolicy: 'retain',
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The name folded, as the server keys it (`foldName`): `name` is a TypeLowerCaseString
|
|
60
|
+
* (builtin/credential/jwt/path_role.go:77), so the handler reads it lowercased before it ever builds
|
|
61
|
+
* `role/<name>` (:289). `App` → `app` is the same role, never a move; `problems` refuses the case.
|
|
62
|
+
*/
|
|
63
|
+
const IDENTITY = roleIdentity<BaoJwtRoleAttributes>(
|
|
64
|
+
'Bao.JwtRole',
|
|
65
|
+
(mount, name) => rolePath({ mount, name }),
|
|
66
|
+
'jwt',
|
|
67
|
+
foldName,
|
|
68
|
+
);
|
|
69
|
+
|
|
57
70
|
export const jwtRoleSpec = (props: BaoJwtRoleProps): RoleSpec<BaoJwtRoleAttributes> => ({
|
|
58
71
|
attributesOf: (live) => attributesOf(props, live),
|
|
59
72
|
body: writeBody(props),
|
|
@@ -80,15 +93,18 @@ export const BaoJwtRoleProvider = () =>
|
|
|
80
93
|
}),
|
|
81
94
|
|
|
82
95
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST — a hand-widened audience is drift. */
|
|
83
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
96
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
97
|
+
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
98
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
99
|
+
if (output === undefined) return undefined;
|
|
100
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
101
|
+
if (!isResolved(news)) return undefined;
|
|
88
102
|
return { action: yield* planRole(jwtRoleSpec(news)) } as const;
|
|
89
103
|
}),
|
|
90
104
|
|
|
91
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
105
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
106
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
107
|
+
yield* guardRename(IDENTITY, news, output);
|
|
92
108
|
return yield* reconcileRole(jwtRoleSpec(news));
|
|
93
109
|
}),
|
|
94
110
|
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* and nothing else (path_role.go:239-256).
|
|
10
10
|
*
|
|
11
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
|
|
12
|
+
* path cannot collide with the old). Under the default `retain` the old role stays live until
|
|
13
|
+
* removed by hand. ⛔ A move onto a role that already exists fails the plan (rename-identity.ts).
|
|
13
14
|
*/
|
|
14
15
|
import { Resource } from 'alchemy';
|
|
15
16
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -22,11 +23,11 @@ import {
|
|
|
22
23
|
type BaoKubernetesRoleProps,
|
|
23
24
|
attributesOf,
|
|
24
25
|
matches,
|
|
25
|
-
mountOf,
|
|
26
26
|
problems,
|
|
27
27
|
rolePath,
|
|
28
28
|
writeBody,
|
|
29
29
|
} from './kubernetes-role-form.ts';
|
|
30
|
+
import { foldName, guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
30
31
|
import { type RoleSpec, planRole, readRoleAt, reconcileRole } from './role-reconcile.ts';
|
|
31
32
|
|
|
32
33
|
export type {
|
|
@@ -47,6 +48,14 @@ export const BaoKubernetesRole = Resource<BaoKubernetesRole>('Bao.KubernetesRole
|
|
|
47
48
|
defaultRemovalPolicy: 'retain',
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
/** The name folded, as the server stores it (`foldName`). */
|
|
52
|
+
const IDENTITY = roleIdentity<BaoKubernetesRoleAttributes>(
|
|
53
|
+
'Bao.KubernetesRole',
|
|
54
|
+
(mount, name) => rolePath({ mount, name }),
|
|
55
|
+
'kubernetes',
|
|
56
|
+
foldName,
|
|
57
|
+
);
|
|
58
|
+
|
|
50
59
|
export const kubernetesRoleSpec = (
|
|
51
60
|
props: BaoKubernetesRoleProps,
|
|
52
61
|
): RoleSpec<BaoKubernetesRoleAttributes> => ({
|
|
@@ -72,15 +81,18 @@ export const BaoKubernetesRoleProvider = () =>
|
|
|
72
81
|
}),
|
|
73
82
|
|
|
74
83
|
/** ⛔ IT COMPARES THE LIVE ROLE, NOT THE STORED DIGEST. */
|
|
75
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
84
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
85
|
+
// ⛔ The identity first, before isResolved; onto a role that exists fails the plan.
|
|
86
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
87
|
+
if (output === undefined) return undefined;
|
|
88
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
89
|
+
if (!isResolved(news)) return undefined;
|
|
80
90
|
return { action: yield* planRole(kubernetesRoleSpec(news)) } as const;
|
|
81
91
|
}),
|
|
82
92
|
|
|
83
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
93
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
94
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
95
|
+
yield* guardRename(IDENTITY, news, output);
|
|
84
96
|
return yield* reconcileRole(kubernetesRoleSpec(news));
|
|
85
97
|
}),
|
|
86
98
|
|
|
@@ -19,9 +19,12 @@
|
|
|
19
19
|
* default `retain` it never runs; with `destroy`, set `retain` to drop it from state and leave it
|
|
20
20
|
* live — which is what the server would do anyway.
|
|
21
21
|
*
|
|
22
|
-
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* ★ REPLACE SEMANTICS (REPLACE.md): `name` changed → `replace`, create-first. Under `retain` BOTH
|
|
23
|
+
* enforcements then apply — fail closed. Under `destroy` the old one's delete is refused, so the
|
|
24
|
+
* deploy stops after creating. ⛔ A rename onto an enforcement that exists fails the plan
|
|
25
|
+
* (rename-identity.ts): the upsert by name would otherwise rewrite, in place, what someone else's
|
|
26
|
+
* enforcement demands. MEASURED 2026-09-21: a swap of two under `destroy` wrote each over the
|
|
27
|
+
* other, then stopped on the two refused deletes.
|
|
25
28
|
*/
|
|
26
29
|
import { Resource } from 'alchemy';
|
|
27
30
|
import { isResolved } from 'alchemy/Diff';
|
|
@@ -38,7 +41,8 @@ import {
|
|
|
38
41
|
problems,
|
|
39
42
|
writeBody,
|
|
40
43
|
} from './mfa-enforcement-form.ts';
|
|
41
|
-
import { authAccessors, readEnforcement, writeEnforcement } from './mfa-wire.ts';
|
|
44
|
+
import { authAccessors, enforcementPath, readEnforcement, writeEnforcement } from './mfa-wire.ts';
|
|
45
|
+
import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
|
|
42
46
|
|
|
43
47
|
export type {
|
|
44
48
|
BaoMfaLoginEnforcementAttributes,
|
|
@@ -59,6 +63,12 @@ export const BaoMfaLoginEnforcement = Resource<BaoMfaLoginEnforcement>('Bao.MfaL
|
|
|
59
63
|
|
|
60
64
|
type Env = HttpClient.HttpClient;
|
|
61
65
|
|
|
66
|
+
/** Exact: the identity store indexes enforcements by (namespace, name) as given. */
|
|
67
|
+
const IDENTITY = nameIdentity<BaoMfaLoginEnforcementAttributes>(
|
|
68
|
+
'Bao.MfaLoginEnforcement',
|
|
69
|
+
enforcementPath,
|
|
70
|
+
);
|
|
71
|
+
|
|
62
72
|
const refuse = (name: string, message: string): Effect.Effect<never> =>
|
|
63
73
|
Effect.die(new Error(`Bao.MfaLoginEnforcement ${name}: ${message}`));
|
|
64
74
|
|
|
@@ -119,13 +129,18 @@ export const BaoMfaLoginEnforcementProvider = () =>
|
|
|
119
129
|
}),
|
|
120
130
|
|
|
121
131
|
/** ⛔ IT COMPARES THE LIVE ENFORCEMENT — a target removed by hand is drift, not a noop. */
|
|
122
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
123
|
-
|
|
124
|
-
|
|
132
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
133
|
+
// ⛔ The name first, before isResolved; onto an enforcement that exists fails the plan.
|
|
134
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
135
|
+
if (output === undefined) return undefined;
|
|
136
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
137
|
+
if (!isResolved(news)) return undefined;
|
|
125
138
|
return { action: yield* planEnforcement(news) } as const;
|
|
126
139
|
}),
|
|
127
140
|
|
|
128
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
141
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
142
|
+
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
143
|
+
yield* guardRename(IDENTITY, news, output);
|
|
129
144
|
return yield* reconcileEnforcement(news);
|
|
130
145
|
}),
|
|
131
146
|
|
package/src/openbao/mfa-totp.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
writeBody,
|
|
32
32
|
} from './mfa-totp-form.ts';
|
|
33
33
|
import { deleteTotp, findTotp, writeTotp } from './mfa-wire.ts';
|
|
34
|
+
import { declaredString } from './rename.ts';
|
|
34
35
|
|
|
35
36
|
export type {
|
|
36
37
|
BaoMfaTotpMethodAttributes,
|
|
@@ -50,8 +51,8 @@ export const BaoMfaTotpMethod = Resource<BaoMfaTotpMethod>('Bao.MfaTotpMethod',
|
|
|
50
51
|
defaultRemovalPolicy: 'retain',
|
|
51
52
|
});
|
|
52
53
|
|
|
53
|
-
const refuse = (
|
|
54
|
-
Effect.die(new Error(`Bao.MfaTotpMethod ${
|
|
54
|
+
const refuse = (name: string, message: string): Effect.Effect<never> =>
|
|
55
|
+
Effect.die(new Error(`Bao.MfaTotpMethod ${name}: ${message}`));
|
|
55
56
|
|
|
56
57
|
export const readTotp = (
|
|
57
58
|
props: BaoMfaTotpMethodProps,
|
|
@@ -75,13 +76,13 @@ export const reconcileTotp = (
|
|
|
75
76
|
): Effect.Effect<BaoMfaTotpMethodAttributes, BaoError, HttpClient.HttpClient> =>
|
|
76
77
|
Effect.gen(function* () {
|
|
77
78
|
const bad = problems(props);
|
|
78
|
-
if (bad.length > 0) return yield* refuse(props, bad.join('; '));
|
|
79
|
+
if (bad.length > 0) return yield* refuse(props.name, bad.join('; '));
|
|
79
80
|
const live = yield* readTotp(props);
|
|
80
81
|
if (live === undefined || !matches(live, props)) yield* writeTotp(writeBody(props));
|
|
81
82
|
const after = yield* readTotp(props);
|
|
82
83
|
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.');
|
|
84
|
+
return yield* refuse(props.name, 'the write succeeded but no method has that name.');
|
|
85
|
+
if (!matches(after, props)) return yield* refuse(props.name, 'the method read back different.');
|
|
85
86
|
return after;
|
|
86
87
|
});
|
|
87
88
|
|
|
@@ -97,14 +98,26 @@ export const BaoMfaTotpMethodProvider = () =>
|
|
|
97
98
|
return yield* readTotp(olds);
|
|
98
99
|
}),
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* ⛔ THE NAME IS JUDGED BEFORE `isResolved(news)`. Until 2026-09-21 it came after, so a
|
|
103
|
+
* rename landing with a pending `issuer` (or any other Output) planned `update`, and the
|
|
104
|
+
* upsert by name wrote a SECOND method: the header's stranding, reached through a green
|
|
105
|
+
* plan. MEASURED through the engine (rename-families.test.ts).
|
|
106
|
+
*/
|
|
100
107
|
diff: Effect.fn(function* ({ news, output }) {
|
|
108
|
+
const name = declaredString(news, 'name');
|
|
109
|
+
if (output !== undefined && name !== undefined) {
|
|
110
|
+
const rename = renameProblem(output.name, name);
|
|
111
|
+
if (rename !== undefined) return yield* refuse(name, rename);
|
|
112
|
+
}
|
|
101
113
|
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
114
|
return { action: yield* planTotp(news) } as const;
|
|
105
115
|
}),
|
|
106
116
|
|
|
107
|
-
|
|
117
|
+
/** ⛔ A rename the diff could not see (the name was an Output), refused before any write. */
|
|
118
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
119
|
+
const rename = output === undefined ? undefined : renameProblem(output.name, news.name);
|
|
120
|
+
if (rename !== undefined) return yield* refuse(news.name, rename);
|
|
108
121
|
return yield* reconcileTotp(news);
|
|
109
122
|
}),
|
|
110
123
|
|
package/src/openbao/pki-role.ts
CHANGED
|
@@ -44,7 +44,6 @@ import * as Provider from 'alchemy/Provider';
|
|
|
44
44
|
import * as Effect from 'effect/Effect';
|
|
45
45
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
46
46
|
import { baoDelete, baoRead, baoWrite } from './bao-http.ts';
|
|
47
|
-
import { mountPath } from './mount-form.ts';
|
|
48
47
|
import {
|
|
49
48
|
type BaoPkiRoleAttributes,
|
|
50
49
|
type BaoPkiRoleProps,
|
|
@@ -54,6 +53,7 @@ import {
|
|
|
54
53
|
rolePath,
|
|
55
54
|
writeBody,
|
|
56
55
|
} from './pki-role-form.ts';
|
|
56
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
57
57
|
|
|
58
58
|
export type { BaoPkiRoleAttributes, BaoPkiRoleProps };
|
|
59
59
|
|
|
@@ -69,6 +69,13 @@ export const BaoPkiRole = Resource<BaoPkiRole>('Bao.PkiRole', {
|
|
|
69
69
|
defaultRemovalPolicy: 'retain',
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
/** Exact: the engine stores `role/<name>` verbatim (builtin/logical/pki/path_roles.go:963). */
|
|
73
|
+
const IDENTITY = roleIdentity<BaoPkiRoleAttributes>(
|
|
74
|
+
'Bao.PkiRole',
|
|
75
|
+
(mount, name) => rolePath({ mount, name }),
|
|
76
|
+
'pki',
|
|
77
|
+
);
|
|
78
|
+
|
|
72
79
|
const readRole = (props: BaoPkiRoleProps) =>
|
|
73
80
|
Effect.gen(function* () {
|
|
74
81
|
const live = yield* baoRead(rolePath(props));
|
|
@@ -100,18 +107,21 @@ export const BaoPkiRoleProvider = () =>
|
|
|
100
107
|
* fields is also what stops a field being added to props and quietly forgotten in
|
|
101
108
|
* the comparison.
|
|
102
109
|
*/
|
|
103
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
104
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
110
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
105
111
|
/**
|
|
106
112
|
* ⚠️ A ROLE IS IDENTIFIED BY MOUNT **AND** NAME. Moving a declaration to another
|
|
107
113
|
* engine is a different object under a different CA, never an in-place edit —
|
|
108
|
-
* `replace`, so Alchemy creates the new one and retires the old.
|
|
114
|
+
* `replace`, so Alchemy creates the new one and retires the old. Judged before
|
|
115
|
+
* `isResolved(news)`, and ⛔ a move onto a role that exists fails the plan
|
|
116
|
+
* (rename-identity.ts).
|
|
109
117
|
* ⛔ A RENAME WAS NOT CAUGHT UNTIL 2026-09-21 (REPLACE.md): only the mount was compared,
|
|
110
118
|
* so a new `name` read nothing at the new path, planned `update`, wrote the new role,
|
|
111
119
|
* and left the old one issuing certificates under no state record at all.
|
|
112
120
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
121
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
122
|
+
if (output === undefined) return undefined;
|
|
123
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
124
|
+
if (!isResolved(news)) return undefined;
|
|
115
125
|
/**
|
|
116
126
|
* ⚠️ A DECLARATION reconcile WOULD REFUSE MUST NEVER PLAN AS noop. An empty
|
|
117
127
|
* allowedDomains, or a duration OpenBao cannot parse, can compare equal to a
|
|
@@ -127,8 +137,10 @@ export const BaoPkiRoleProvider = () =>
|
|
|
127
137
|
: ({ action: 'update' } as const);
|
|
128
138
|
}),
|
|
129
139
|
|
|
130
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
140
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
131
141
|
const path = rolePath(news);
|
|
142
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
143
|
+
yield* guardRename(IDENTITY, news, output);
|
|
132
144
|
/**
|
|
133
145
|
* ⛔ AN EMPTY allowedDomains IS A REFUSAL, NOT AN EMPTY ROLE. `allow_any_name` is
|
|
134
146
|
* false on every live role, so a role with no allowed domains can issue NOTHING —
|
package/src/openbao/plugin.ts
CHANGED
|
@@ -24,9 +24,18 @@ import { isResolved } from 'alchemy/Diff';
|
|
|
24
24
|
import * as Provider from 'alchemy/Provider';
|
|
25
25
|
import * as Effect from 'effect/Effect';
|
|
26
26
|
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
type BaoPluginAttributes,
|
|
29
|
+
type BaoPluginProps,
|
|
30
|
+
type BaoPluginType,
|
|
31
|
+
matches,
|
|
32
|
+
resolve,
|
|
33
|
+
versionedPath,
|
|
34
|
+
} from './plugin-form.ts';
|
|
28
35
|
import { readEntry, reconcilePlugin } from './plugin-reconcile.ts';
|
|
29
36
|
import { deregisterPlugin } from './plugin-wire.ts';
|
|
37
|
+
import { declaredOr, declaredString } from './rename.ts';
|
|
38
|
+
import { type Identity, guardRename, judgeRename } from './rename-identity.ts';
|
|
30
39
|
|
|
31
40
|
export type { BaoPluginAttributes, BaoPluginProps, BaoPluginType } from './plugin-form.ts';
|
|
32
41
|
|
|
@@ -42,6 +51,24 @@ export const BaoPlugin = Resource<BaoPlugin>('Bao.Plugin', {
|
|
|
42
51
|
defaultRemovalPolicy: 'retain',
|
|
43
52
|
});
|
|
44
53
|
|
|
54
|
+
/**
|
|
55
|
+
* ⛔ NAME, TYPE AND VERSION ARE THE CATALOG KEY, so the identity is the versioned path a read and a
|
|
56
|
+
* delete address. Exact: the catalog stores `<type>/<name>/<version>` as given, and the version
|
|
57
|
+
* is already canonical (`problems`). ⚠️ An unversioned name that is a builtin reads back as
|
|
58
|
+
* present (plugin-wire.ts), so a move onto one fails the plan, as reconcile would refuse anyway.
|
|
59
|
+
*/
|
|
60
|
+
const IDENTITY: Identity<BaoPluginAttributes> = {
|
|
61
|
+
declared: (props) => {
|
|
62
|
+
const type = declaredString(props, 'type') as BaoPluginType | undefined;
|
|
63
|
+
const name = declaredString(props, 'name');
|
|
64
|
+
const version = declaredOr(props, 'version', '');
|
|
65
|
+
if (type === undefined || name === undefined || version === undefined) return undefined;
|
|
66
|
+
return versionedPath(type, name, version);
|
|
67
|
+
},
|
|
68
|
+
family: 'Bao.Plugin',
|
|
69
|
+
recorded: (output) => versionedPath(output.type, output.name, output.version),
|
|
70
|
+
};
|
|
71
|
+
|
|
45
72
|
export const BaoPluginProvider = () =>
|
|
46
73
|
Provider.effect(
|
|
47
74
|
BaoPlugin,
|
|
@@ -60,21 +87,18 @@ export const BaoPluginProvider = () =>
|
|
|
60
87
|
}),
|
|
61
88
|
|
|
62
89
|
/** ⛔ IT COMPARES THE LIVE ENTRY, NOT THE STORED DIGEST — a hand re-register is drift. */
|
|
63
|
-
diff: Effect.fn(function* ({ news, output }) {
|
|
64
|
-
if (output === undefined || !isResolved(news)) return undefined;
|
|
65
|
-
const form = resolve(news);
|
|
90
|
+
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
66
91
|
/**
|
|
67
|
-
* ⛔ NAME, TYPE
|
|
68
|
-
*
|
|
69
|
-
* state record that no longer names it.
|
|
92
|
+
* ⛔ A NEW NAME, TYPE OR VERSION IS A DIFFERENT ENTRY, not an edit: `replace`, judged
|
|
93
|
+
* before `isResolved(news)`. Writing the new key in place would leave the old
|
|
94
|
+
* registration under a state record that no longer names it. ⛔ A move onto an entry
|
|
95
|
+
* that is already registered fails the plan (rename-identity.ts).
|
|
70
96
|
*/
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
)
|
|
76
|
-
return { action: 'replace' } as const;
|
|
77
|
-
}
|
|
97
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
98
|
+
if (output === undefined) return undefined;
|
|
99
|
+
if (move !== undefined) return { action: 'replace' } as const;
|
|
100
|
+
if (!isResolved(news)) return undefined;
|
|
101
|
+
const form = resolve(news);
|
|
78
102
|
const found = yield* readEntry(form);
|
|
79
103
|
if (found === undefined) return { action: 'update' } as const;
|
|
80
104
|
return matches(found.attributes, form)
|
|
@@ -83,7 +107,9 @@ export const BaoPluginProvider = () =>
|
|
|
83
107
|
}),
|
|
84
108
|
|
|
85
109
|
/** The refusals — declarative, builtin, self-reported version — live in plugin-reconcile.ts. */
|
|
86
|
-
reconcile: Effect.fn(function* ({ news }) {
|
|
110
|
+
reconcile: Effect.fn(function* ({ news, output }) {
|
|
111
|
+
// ⛔ An `update` across a move the diff could not see — refused before any write.
|
|
112
|
+
yield* guardRename(IDENTITY, news, output);
|
|
87
113
|
return yield* reconcilePlugin(news);
|
|
88
114
|
}),
|
|
89
115
|
|
package/src/openbao/policy.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
|
8
8
|
import { sha256 } from './digest.ts';
|
|
9
9
|
import { isEmptyAssembly } from './policy-assembly.ts';
|
|
10
10
|
import { deletePolicy, policyPath, readPolicy, writePolicy } from './policy-wire.ts';
|
|
11
|
-
import {
|
|
11
|
+
import { policyKey } from './rename.ts';
|
|
12
|
+
import { guardRename, judgeRename, nameIdentity } from './rename-identity.ts';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* An OpenBao ACL policy, assembled from the HCL fragments that declare it.
|
|
@@ -99,6 +100,9 @@ export const BaoPolicy = Resource<BaoPolicy>('Bao.Policy', {
|
|
|
99
100
|
defaultRemovalPolicy: 'retain',
|
|
100
101
|
});
|
|
101
102
|
|
|
103
|
+
/** A policy is its name, keyed as OpenBao keys it (`policyKey`). */
|
|
104
|
+
const IDENTITY = nameIdentity<BaoPolicyAttributes>('Bao.Policy', policyPath, policyKey);
|
|
105
|
+
|
|
102
106
|
/** `path "..."` grant count — the same number the apply script prints. */
|
|
103
107
|
const grantsOf = (hcl: string) => (hcl.match(/^path /gm) ?? []).length;
|
|
104
108
|
|
|
@@ -156,9 +160,7 @@ export const BaoPolicyProvider = () =>
|
|
|
156
160
|
* delete, so a role outside this graph that names the old policy must move in the same
|
|
157
161
|
* PR (REPLACE.md).
|
|
158
162
|
*/
|
|
159
|
-
const
|
|
160
|
-
const declared = declaredString(news, 'name');
|
|
161
|
-
const move = yield* judgeMove('Bao.Policy', tried, declared, policyPath, policyKey);
|
|
163
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
162
164
|
if (output === undefined) return undefined;
|
|
163
165
|
if (move !== undefined) return { action: 'replace' } as const;
|
|
164
166
|
// ⚠️ A prop can still be an unresolved Output or Config at plan time. Docker's
|
|
@@ -175,9 +177,7 @@ export const BaoPolicyProvider = () =>
|
|
|
175
177
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
176
178
|
const name = news.name;
|
|
177
179
|
// ⛔ An `update` across a rename the diff could not see — refused before any write.
|
|
178
|
-
|
|
179
|
-
return yield* refuseMovedUpdate(`Bao.Policy ${name}`, output.name, name);
|
|
180
|
-
}
|
|
180
|
+
yield* guardRename(IDENTITY, news, output);
|
|
181
181
|
const { joined, parts } = yield* assemble(news.fragments);
|
|
182
182
|
/**
|
|
183
183
|
* ⛔ AN EMPTY ASSEMBLY IS A REFUSAL, NOT AN EMPTY POLICY. `bao policy write` with
|
|
@@ -50,15 +50,8 @@ import {
|
|
|
50
50
|
writeBody,
|
|
51
51
|
} from './proxmox-role-form.ts';
|
|
52
52
|
import { readRole } from './proxmox-role-wire.ts';
|
|
53
|
-
import {
|
|
54
|
-
|
|
55
|
-
declaredString,
|
|
56
|
-
isMoved,
|
|
57
|
-
isPendingProp,
|
|
58
|
-
judgeMove,
|
|
59
|
-
refuseMovedUpdate,
|
|
60
|
-
triedRolePath,
|
|
61
|
-
} from './rename.ts';
|
|
53
|
+
import { declaredString, isPendingProp } from './rename.ts';
|
|
54
|
+
import { guardRename, judgeRename, roleIdentity } from './rename-identity.ts';
|
|
62
55
|
|
|
63
56
|
export type { BaoProxmoxRoleAttributes, BaoProxmoxRoleProps };
|
|
64
57
|
|
|
@@ -74,6 +67,9 @@ export const BaoProxmoxRole = Resource<BaoProxmoxRole>('Bao.ProxmoxRole', {
|
|
|
74
67
|
defaultRemovalPolicy: 'retain',
|
|
75
68
|
});
|
|
76
69
|
|
|
70
|
+
/** Exact: the plugin stores `roles/<name>` verbatim. */
|
|
71
|
+
const IDENTITY = roleIdentity<BaoProxmoxRoleAttributes>('Bao.ProxmoxRole', rolePath);
|
|
72
|
+
|
|
77
73
|
export const BaoProxmoxRoleProvider = () =>
|
|
78
74
|
Provider.effect(
|
|
79
75
|
BaoProxmoxRole,
|
|
@@ -110,9 +106,7 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
110
106
|
* still an Output it cannot be checked, so the diff defers, and reconcile refuses the
|
|
111
107
|
* resulting `update`.
|
|
112
108
|
*/
|
|
113
|
-
const
|
|
114
|
-
const declared = declaredRolePath(news, rolePath);
|
|
115
|
-
const move = yield* judgeMove('Bao.ProxmoxRole', tried, declared, (path) => path);
|
|
109
|
+
const move = yield* judgeRename(IDENTITY, olds, news, output);
|
|
116
110
|
if (output === undefined) return undefined;
|
|
117
111
|
if (move !== undefined) {
|
|
118
112
|
const mintUser = declaredString(news, 'mintUser');
|
|
@@ -151,10 +145,7 @@ export const BaoProxmoxRoleProvider = () =>
|
|
|
151
145
|
reconcile: Effect.fn(function* ({ news, output }) {
|
|
152
146
|
const path = rolePath(news.mount, news.name);
|
|
153
147
|
// ⛔ An `update` across a move the diff could not see — refused before any read or write.
|
|
154
|
-
|
|
155
|
-
if (isMoved(before, path) === true) {
|
|
156
|
-
return yield* refuseMovedUpdate('Bao.ProxmoxRole', before, path);
|
|
157
|
-
}
|
|
148
|
+
yield* guardRename(IDENTITY, news, output);
|
|
158
149
|
const live = yield* readRole(news);
|
|
159
150
|
/**
|
|
160
151
|
* ⛔ A mint_user CHANGE RE-SCOPES EVERY CREDENTIAL THE ROLE WILL EVER MINT, SILENTLY.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The swap the plan-time guard cannot see: a NEW declaration of a name that is live. 🔴 MEASURED
|
|
3
|
+
* 2026-09-21 through Alchemy's own plan and apply (fake-stack.ts). With no state row, the engine asks
|
|
4
|
+
* `read`, which finds the object and answers plain attributes, so Alchemy silently adopts it
|
|
5
|
+
* (alchemy@2.0.0-beta.79 Plan.ts, the adoption probe; AdoptPolicy.ts). Nothing moved, so
|
|
6
|
+
* `judgeMove` never runs. Under `RemovalPolicy.destroy()` the old owner's delete then runs in
|
|
7
|
+
* Apply's phase 2, after the adoption has written, and removes the object the new declaration now
|
|
8
|
+
* claims. The deploy is green.
|
|
9
|
+
*
|
|
10
|
+
* ★ These pin that, and the two ways round it REPLACE.md gives: `renamedFrom` for a logical id that
|
|
11
|
+
* changes, and a second deploy for a name another resource is leaving. If one of the hazards stops
|
|
12
|
+
* reproducing, the engine changed: re-read REPLACE.md before relaxing it.
|
|
13
|
+
*
|
|
14
|
+
* ⛔ TEST-ONLY. Every value is a placeholder, not the estate's.
|
|
15
|
+
*/
|
|
16
|
+
import assert from 'node:assert/strict';
|
|
17
|
+
import { describe, it } from 'node:test';
|
|
18
|
+
import { renamedFrom } from 'alchemy';
|
|
19
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
20
|
+
import * as Effect from 'effect/Effect';
|
|
21
|
+
import { FAMILIES, rowOf, withEstate } from './fake-families.ts';
|
|
22
|
+
import { writesOf } from './fake-stack.ts';
|
|
23
|
+
|
|
24
|
+
const { destroy } = RemovalPolicy;
|
|
25
|
+
|
|
26
|
+
/** No delete endpoint (jwt-config.ts), and a delete the provider refuses (mfa-enforcement.ts). */
|
|
27
|
+
const KEEPS = new Set(['Bao.JwtAuthConfig', 'Bao.MfaLoginEnforcement']);
|
|
28
|
+
const REFUSES_DELETE = /Bao\.MfaLoginEnforcement/;
|
|
29
|
+
|
|
30
|
+
describe('a logical id changed without renamedFrom, under destroy', () => {
|
|
31
|
+
for (const row of FAMILIES) {
|
|
32
|
+
it(`${row.family}: the orphan delete removes what the new id just adopted`, async () => {
|
|
33
|
+
await withEstate(async (stack, estate) => {
|
|
34
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
35
|
+
const again = stack.deploy(row.declare('Z', 'a', '15m', destroy));
|
|
36
|
+
if (row.family === 'Bao.MfaLoginEnforcement') await assert.rejects(again, REFUSES_DELETE);
|
|
37
|
+
else assert.deepEqual(await again, { X: 'delete', Z: 'adopted' });
|
|
38
|
+
assert.equal(row.has(estate, 'a'), KEEPS.has(row.family));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('the ways round it', () => {
|
|
45
|
+
const row = rowOf('Bao.AuthRole');
|
|
46
|
+
|
|
47
|
+
it('renamedFrom migrates the state row instead: an update that deletes nothing', async () => {
|
|
48
|
+
await withEstate(async (stack, estate, seen) => {
|
|
49
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
50
|
+
seen.length = 0;
|
|
51
|
+
const moved = row.declare('Z', 'a', '15m', destroy).pipe(renamedFrom('X'));
|
|
52
|
+
assert.deepEqual(await stack.deploy(moved), { Z: 'update' });
|
|
53
|
+
assert.deepEqual(writesOf(seen), []);
|
|
54
|
+
assert.ok(row.has(estate, 'a'));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('a name another resource moves off is lost when taken in the same deploy', async () => {
|
|
59
|
+
await withEstate(async (stack, estate) => {
|
|
60
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
61
|
+
const both = Effect.gen(function* () {
|
|
62
|
+
yield* row.declare('X', 'b', '15m', destroy);
|
|
63
|
+
yield* row.declare('Z', 'a', '30m', destroy);
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(await stack.deploy(both), { X: 'replace', Z: 'adopted' });
|
|
66
|
+
assert.ok(row.has(estate, 'b') && !row.has(estate, 'a'));
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('and survives when taken in the deploy after the move', async () => {
|
|
71
|
+
await withEstate(async (stack, estate) => {
|
|
72
|
+
await stack.deploy(row.declare('X', 'a', '15m', destroy));
|
|
73
|
+
await stack.deploy(row.declare('X', 'b', '15m', destroy));
|
|
74
|
+
const both = Effect.gen(function* () {
|
|
75
|
+
yield* row.declare('X', 'b', '15m', destroy);
|
|
76
|
+
yield* row.declare('Z', 'a', '30m', destroy);
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(await stack.deploy(both), { X: 'noop', Z: 'create' });
|
|
79
|
+
assert.ok(row.has(estate, 'a') && row.has(estate, 'b'));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
});
|