@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
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner over the fake host (fake-sudo.ts): which calls reach sudo, in what exact argv, what
|
|
3
|
+
* is logged, and what is refused before sudo is ever asked. No real sudo runs anywhere.
|
|
4
|
+
* File writes and removes are in sudo-runner-files.test.ts.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, test } from 'bun:test';
|
|
7
|
+
import { OPERATOR, PREFIXES, fakeSudoHost } from './fake-sudo.ts';
|
|
8
|
+
import { LAUNCHCTL } from './launchctl.ts';
|
|
9
|
+
import { RM, SUDO } from './sudo-allowlist.ts';
|
|
10
|
+
import { SudoRefusedError, makeSudoRunner, sudoRunner } from './sudo-runner.ts';
|
|
11
|
+
|
|
12
|
+
const PLIST = '/Library/LaunchDaemons/com.example.a.plist';
|
|
13
|
+
const plistBytes = new TextEncoder().encode('<key>Label</key><string>com.example.a</string>');
|
|
14
|
+
|
|
15
|
+
describe('what it claims', () => {
|
|
16
|
+
test('privileged, as the operator: it elevates calls, it is not root', () => {
|
|
17
|
+
const { runner } = fakeSudoHost();
|
|
18
|
+
expect(runner.privileged).toBe(true);
|
|
19
|
+
expect(runner.effectiveUid()).toBe(OPERATOR);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('prefixes are required and checked when it is built', () => {
|
|
23
|
+
expect(() => sudoRunner({ prefixes: [] })).toThrow('no prefixes');
|
|
24
|
+
expect(() => sudoRunner({ prefixes: ['/'] })).toThrow('every path');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('exec', () => {
|
|
29
|
+
test('a system bootstrap runs as `sudo -n --`, logged once, argv only', async () => {
|
|
30
|
+
const host = fakeSudoHost();
|
|
31
|
+
host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
32
|
+
const result = await host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
|
|
33
|
+
expect(result.exitCode).toBe(0);
|
|
34
|
+
expect(host.sudoCalls).toEqual([[SUDO, '-n', '--', LAUNCHCTL, 'bootstrap', 'system', PLIST]]);
|
|
35
|
+
expect(host.logs).toEqual([
|
|
36
|
+
`homeflare/launchd sudo -n ${JSON.stringify([LAUNCHCTL, 'bootstrap', 'system', PLIST])}`,
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('bootstrap of a plist that is not a plain file is refused before sudo', async () => {
|
|
41
|
+
const host = fakeSudoHost();
|
|
42
|
+
const missing = host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
|
|
43
|
+
// ★ The host checks throw the same class as the allowlist: nothing ran as root.
|
|
44
|
+
await expect(missing).rejects.toBeInstanceOf(SudoRefusedError);
|
|
45
|
+
await expect(missing).rejects.toThrow('is missing, not a file');
|
|
46
|
+
host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'symlink', mode: 0o755, uid: 0 });
|
|
47
|
+
await expect(host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST])).rejects.toThrow(
|
|
48
|
+
'is a symlink',
|
|
49
|
+
);
|
|
50
|
+
expect(host.sudoCalls).toEqual([]);
|
|
51
|
+
expect(host.logs).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('reads stay unelevated: no sudo, no log line', async () => {
|
|
55
|
+
const host = fakeSudoHost();
|
|
56
|
+
const result = await host.runner.exec([LAUNCHCTL, 'print', 'system/com.example.a']);
|
|
57
|
+
expect(result.exitCode).toBe(113);
|
|
58
|
+
await host.runner.exec([LAUNCHCTL, 'print-disabled', 'system']);
|
|
59
|
+
expect(host.sudoCalls).toEqual([]);
|
|
60
|
+
expect(host.logs).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test.each([
|
|
64
|
+
['a bare-domain bootout', [LAUNCHCTL, 'bootout', 'system']],
|
|
65
|
+
["another user's gui domain", [LAUNCHCTL, 'bootout', 'gui/502/com.example.a']],
|
|
66
|
+
['a sudo argv', [SUDO, '-n', RM, '-rf', '/']],
|
|
67
|
+
])('%s is refused before sudo is asked', async (_name, argv) => {
|
|
68
|
+
const host = fakeSudoHost();
|
|
69
|
+
await expect(host.runner.exec(argv)).rejects.toBeInstanceOf(SudoRefusedError);
|
|
70
|
+
expect(host.sudoCalls).toEqual([]);
|
|
71
|
+
expect(host.logs).toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a password-required sudo fails at once, saying what to do', async () => {
|
|
75
|
+
const host = fakeSudoHost();
|
|
76
|
+
host.state.sudo = 'password';
|
|
77
|
+
const run = host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
|
|
78
|
+
await expect(run).rejects.toBeInstanceOf(SudoRefusedError);
|
|
79
|
+
await expect(run).rejects.toThrow('a password is required');
|
|
80
|
+
await expect(run).rejects.toThrow('sudo -v');
|
|
81
|
+
expect(host.sudoCalls).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test.each(['denied', 'not-in-sudoers', 'not-on-host'] as const)(
|
|
85
|
+
'a sudoers refusal (%s) is named as one',
|
|
86
|
+
async (refusal) => {
|
|
87
|
+
const host = fakeSudoHost();
|
|
88
|
+
host.state.sudo = refusal;
|
|
89
|
+
const run = host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
|
|
90
|
+
await expect(run).rejects.toBeInstanceOf(SudoRefusedError);
|
|
91
|
+
await expect(run).rejects.toThrow('sudoers does not let');
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
test("launchctl's own failure comes back as a result, for the provider to judge", async () => {
|
|
96
|
+
const host = fakeSudoHost();
|
|
97
|
+
const result = await host.runner.exec([LAUNCHCTL, 'bootout', 'system/com.example.a']);
|
|
98
|
+
expect(result.exitCode).toBe(3);
|
|
99
|
+
expect(host.sudoCalls).toHaveLength(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a launchctl error that says "not allowed" is still launchctl\'s, not sudo\'s', async () => {
|
|
103
|
+
const host = fakeSudoHost();
|
|
104
|
+
host.fake.files.set(PLIST, { bytes: plistBytes, gid: 0, kind: 'file', mode: 0o644, uid: 0 });
|
|
105
|
+
// ⚠️ Exit 1 like sudo's own failures; only sudo's message forms may turn it into a refusal.
|
|
106
|
+
host.fake.state.bootstrapFailure = {
|
|
107
|
+
exitCode: 1,
|
|
108
|
+
stderr: 'Bootstrap failed: 1: service is not allowed to execute in this domain',
|
|
109
|
+
stdout: '',
|
|
110
|
+
};
|
|
111
|
+
const result = await host.runner.exec([LAUNCHCTL, 'bootstrap', 'system', PLIST]);
|
|
112
|
+
expect(result.exitCode).toBe(1);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('makeSudoRunner validates prefixes too', () => {
|
|
117
|
+
const { fake } = fakeSudoHost();
|
|
118
|
+
const deps = {
|
|
119
|
+
base: fake.runner,
|
|
120
|
+
groups: async () => [],
|
|
121
|
+
log: () => undefined,
|
|
122
|
+
stage: async () => ({ dispose: async () => undefined, path: '' }),
|
|
123
|
+
};
|
|
124
|
+
expect(() => makeSudoRunner([], deps)).toThrow('no prefixes');
|
|
125
|
+
expect(() => makeSudoRunner(PREFIXES, deps)).not.toThrow();
|
|
126
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sudoRunner(): the kit's one elevating HostRunner. The deploy runs as the operator, and only the
|
|
3
|
+
* calls that need root go through `sudo -n`, in the exact shapes sudo-allowlist.ts names:
|
|
4
|
+
*
|
|
5
|
+
* - `launchctl bootstrap | bootout | kickstart` in the system domain;
|
|
6
|
+
* - a file under a prefix the stack declared: staged in a private temp file as the operator, then
|
|
7
|
+
* `sudo -n install -S -m <mode> [-o <uid>] [-g <gid>] <staged> <path>`; removed with
|
|
8
|
+
* `sudo -n rm -f -- <path>`.
|
|
9
|
+
*
|
|
10
|
+
* Everything else (reads, lookups, the operator's own files and gui domain) goes to localRunner().
|
|
11
|
+
*
|
|
12
|
+
* ★ DECIDED 2026-09-21. A host stack deploys as the operator rather than as root, so a provider bug
|
|
13
|
+
* or a wrong declaration can do no more than this list. It is an explicit opt-in,
|
|
14
|
+
* `launchdProviders(sudoRunner({ prefixes }))`, and nothing ever falls back to it: localRunner()
|
|
15
|
+
* stays the default and never elevates.
|
|
16
|
+
* ⛔ `sudo -n`, NEVER A PROMPT. A deploy that stops mid-apply to ask for a password is the failure
|
|
17
|
+
* "no silent sudo" exists to prevent. With -n, sudo fails at once, and that failure becomes an
|
|
18
|
+
* error saying what to do.
|
|
19
|
+
* ⛔ EVERY PRIVILEGED ARGV IS LOGGED BEFORE IT RUNS, and it is only ever an argv: a file's bytes
|
|
20
|
+
* travel through the staged file, never through argv, so a log line cannot carry content.
|
|
21
|
+
* ★ `install -S`: install(1) on macOS 27.2 (read 2026-09-21) always writes a temp file in the
|
|
22
|
+
* target directory and renames it, so the file is never torn, and -S adds the fsync that
|
|
23
|
+
* localRunner's `handle.sync()` does.
|
|
24
|
+
* ⚠️ REASONED, NOT MEASURED: sudo's failure text. The kit never runs sudo. The strings below follow
|
|
25
|
+
* sudoers(5) for sudo 1.9.17p2 on macOS 27.2 ("a password is required": -n was given but a
|
|
26
|
+
* password was needed) and sudo(8) (sudo exits 1 when it fails itself).
|
|
27
|
+
*/
|
|
28
|
+
import { type LocalRunnerOptions, errnoCode, localRunner } from './local-runner.ts';
|
|
29
|
+
import type { ExecResult, HostRunner } from './runner.ts';
|
|
30
|
+
import {
|
|
31
|
+
INSTALL,
|
|
32
|
+
RM,
|
|
33
|
+
SUDO,
|
|
34
|
+
SudoRefusedError,
|
|
35
|
+
checkPrefixes,
|
|
36
|
+
octalMode,
|
|
37
|
+
prefixOf,
|
|
38
|
+
privilegedProblem,
|
|
39
|
+
routeExec,
|
|
40
|
+
} from './sudo-allowlist.ts';
|
|
41
|
+
import { assertPlainPath, assertReadableBack, operatorGroups } from './sudo-guard.ts';
|
|
42
|
+
import { type Staged, stageFile } from './sudo-stage.ts';
|
|
43
|
+
|
|
44
|
+
export { SudoRefusedError };
|
|
45
|
+
|
|
46
|
+
export type SudoRunnerOptions = LocalRunnerOptions & {
|
|
47
|
+
/**
|
|
48
|
+
* The directories root may install into and remove from: `/Library/LaunchDaemons` for system
|
|
49
|
+
* jobs (bootstrap and bootout are refused without it), plus wherever the stack's root-owned
|
|
50
|
+
* HostFiles live. ⛔ Required: absolute, normalised, never `/`; and at every privileged call a
|
|
51
|
+
* real directory that root owns and only root may write (sudo-guard.ts). Outside every prefix a
|
|
52
|
+
* file is written as the deploying user, or refused when that needs root.
|
|
53
|
+
*/
|
|
54
|
+
readonly prefixes: readonly string[];
|
|
55
|
+
/** Receives one line per privileged argv, before it runs. @default a line on stderr */
|
|
56
|
+
readonly log?: (line: string) => void;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** What a sudo runner is built from. ★ Split out so the tests swap the host, never the logic. */
|
|
60
|
+
export type SudoDeps = {
|
|
61
|
+
/** Runs everything, including `sudo` itself. localRunner() for the real one. */
|
|
62
|
+
readonly base: HostRunner;
|
|
63
|
+
readonly stage: (bytes: Uint8Array) => Promise<Staged>;
|
|
64
|
+
/** The deploying user's groups, for the read-back check; `undefined` when unknown. */
|
|
65
|
+
readonly groups: () => Promise<readonly number[] | undefined>;
|
|
66
|
+
readonly log: (line: string) => void;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* ⚠️ ANCHORED TO sudo'S OWN MESSAGE FORMS (`sudo: …` lines, `Sorry, user …`), so a launchctl or
|
|
71
|
+
* install error that happens to say "not allowed" is never reported as "the command did not run".
|
|
72
|
+
* The refusals are the three a sudoers denial prints: the command not allowed, the user not in
|
|
73
|
+
* sudoers, and the user not allowed on this host (`<user> is not allowed to run sudo on <host>.`).
|
|
74
|
+
* REASONED from sudo 1.9's sudoers plugin, like the rest (header).
|
|
75
|
+
*/
|
|
76
|
+
const PASSWORD =
|
|
77
|
+
/^sudo: (?:a password is required|a terminal is required|sorry, you must have a tty)/m;
|
|
78
|
+
const NOT_ALLOWED =
|
|
79
|
+
/^(?:Sorry, user \S+ (?:is not allowed to execute|may not run sudo)|\S+ is not (?:in the sudoers file|allowed to run sudo on ))/m;
|
|
80
|
+
|
|
81
|
+
/** ★ An EACCES outside every prefix is almost always a directory the stack forgot to declare. */
|
|
82
|
+
const undeclared =
|
|
83
|
+
(path: string, prefixes: readonly string[]) =>
|
|
84
|
+
(cause: unknown): never => {
|
|
85
|
+
const code = errnoCode(cause);
|
|
86
|
+
if (code !== 'EACCES' && code !== 'EPERM') throw cause;
|
|
87
|
+
throw new Error(
|
|
88
|
+
`sudoRunner ${path}: ${code} as the deploying user, and the path is under no declared prefix ` +
|
|
89
|
+
`(${prefixes.join(', ')}). If root owns its directory, declare that directory as a prefix.`,
|
|
90
|
+
{ cause },
|
|
91
|
+
);
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const makeSudoRunner = (prefixList: readonly string[], deps: SudoDeps): HostRunner => {
|
|
95
|
+
const prefixes = checkPrefixes(prefixList);
|
|
96
|
+
const { base } = deps;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* ⛔ THE ONE PLACE `sudo` IS SPAWNED: allowlist, then any host check (`before`), then the log
|
|
100
|
+
* line, then `sudo -n -- argv`.
|
|
101
|
+
*/
|
|
102
|
+
const elevate = async (
|
|
103
|
+
argv: readonly string[],
|
|
104
|
+
{ before, staged }: { before?: () => Promise<unknown>; staged?: string } = {},
|
|
105
|
+
): Promise<ExecResult> => {
|
|
106
|
+
const shown = JSON.stringify(argv);
|
|
107
|
+
const problem = privilegedProblem(argv, {
|
|
108
|
+
prefixes,
|
|
109
|
+
...(staged === undefined ? {} : { staged }),
|
|
110
|
+
});
|
|
111
|
+
if (problem !== undefined)
|
|
112
|
+
throw new SudoRefusedError(`sudoRunner refused ${shown}: ${problem}`);
|
|
113
|
+
await before?.();
|
|
114
|
+
deps.log(`homeflare/launchd sudo -n ${shown}`);
|
|
115
|
+
const result = await base.exec([SUDO, '-n', '--', ...argv]);
|
|
116
|
+
if (result.exitCode !== 1) return result;
|
|
117
|
+
if (PASSWORD.test(result.stderr)) {
|
|
118
|
+
throw new SudoRefusedError(
|
|
119
|
+
`sudo -n ${shown}: a password is required, and this runner never prompts. Run \`sudo -v\` ` +
|
|
120
|
+
'in the deploying terminal just before the deploy, or grant exactly these commands ' +
|
|
121
|
+
'NOPASSWD (docs/launchd-sudo.md). The command did not run.',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
if (NOT_ALLOWED.test(result.stderr)) {
|
|
125
|
+
throw new SudoRefusedError(
|
|
126
|
+
`sudo -n ${shown}: sudoers does not let the deploying user run this. The command did not run.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** A privileged file operation: anything but exit 0 is an error. ⚠️ stderr names paths only. */
|
|
133
|
+
const mustSucceed = async (argv: readonly string[], staged?: string): Promise<void> => {
|
|
134
|
+
const result = await elevate(argv, staged === undefined ? {} : { staged });
|
|
135
|
+
if (result.exitCode !== 0) {
|
|
136
|
+
const stderr = result.stderr.trim().slice(0, 300);
|
|
137
|
+
throw new Error(`sudo -n ${JSON.stringify(argv)} -> ${String(result.exitCode)}: ${stderr}`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
effectiveUid: () => base.effectiveUid(),
|
|
143
|
+
exec: async (argv) => {
|
|
144
|
+
const route = routeExec(argv, base.effectiveUid());
|
|
145
|
+
if ('refuse' in route) throw new SudoRefusedError(`sudoRunner: ${route.refuse}`);
|
|
146
|
+
if (route.as === 'operator') return base.exec(argv);
|
|
147
|
+
// ⛔ bootstrap makes root read the plist: it must be a plain file exactly where argv says.
|
|
148
|
+
// The allowlist has already put it under a prefix by the time `before` runs.
|
|
149
|
+
const plist = argv[1] === 'bootstrap' ? argv[3] : undefined;
|
|
150
|
+
const prefix = plist === undefined ? undefined : prefixOf(plist, prefixes);
|
|
151
|
+
return elevate(argv, {
|
|
152
|
+
before: async () => {
|
|
153
|
+
if (plist !== undefined && prefix !== undefined) {
|
|
154
|
+
await assertPlainPath(base, prefix, plist, 'file');
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
lookupGroup: (nameOrId) => base.lookupGroup(nameOrId),
|
|
160
|
+
lookupUser: (nameOrId) => base.lookupUser(nameOrId),
|
|
161
|
+
// ★ True, so the providers let a system-domain job through to this runner (canActAsRoot).
|
|
162
|
+
privileged: true,
|
|
163
|
+
readFile: (path) => base.readFile(path),
|
|
164
|
+
removeFile: async (path) => {
|
|
165
|
+
const prefix = prefixOf(path, prefixes);
|
|
166
|
+
if (prefix === undefined) return base.removeFile(path).catch(undeclared(path, prefixes));
|
|
167
|
+
/**
|
|
168
|
+
* ★ Nothing there is success without a privileged call, so an idempotent delete logs nothing.
|
|
169
|
+
* ⚠️ lstat the path FIRST: a directory on the way that is already gone means nothing is there
|
|
170
|
+
* (HostRunner.removeFile is idempotent, and localRunner treats ENOENT/ENOTDIR so), where the
|
|
171
|
+
* guard alone would refuse it as a missing directory.
|
|
172
|
+
*/
|
|
173
|
+
if ((await base.stat(path)) === undefined) return;
|
|
174
|
+
await assertPlainPath(base, prefix, path, 'file');
|
|
175
|
+
await mustSucceed([RM, '-f', '--', path]);
|
|
176
|
+
},
|
|
177
|
+
sleep: (ms) => base.sleep(ms),
|
|
178
|
+
stat: (path) => base.stat(path),
|
|
179
|
+
writeFileAtomic: async (path, bytes, options) => {
|
|
180
|
+
const prefix = prefixOf(path, prefixes);
|
|
181
|
+
const operator = base.effectiveUid();
|
|
182
|
+
if (prefix === undefined) {
|
|
183
|
+
if (options.uid !== undefined && options.uid !== operator && operator !== 0) {
|
|
184
|
+
throw new SudoRefusedError(
|
|
185
|
+
`sudoRunner ${path}: owner ${String(options.uid)} needs root, and the path is under no ` +
|
|
186
|
+
`declared prefix (${prefixes.join(', ')}). Declare its directory, or deploy as root. ` +
|
|
187
|
+
'Nothing was written.',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
return base.writeFileAtomic(path, bytes, options).catch(undeclared(path, prefixes));
|
|
191
|
+
}
|
|
192
|
+
const mode = octalMode(options.mode);
|
|
193
|
+
if (mode === undefined)
|
|
194
|
+
throw new SudoRefusedError(`sudoRunner ${path}: mode must be 0–0o7777`);
|
|
195
|
+
await assertPlainPath(base, prefix, path, 'file-or-absent');
|
|
196
|
+
await assertReadableBack(base, path, options, await deps.groups());
|
|
197
|
+
const staged = await deps.stage(bytes);
|
|
198
|
+
try {
|
|
199
|
+
await mustSucceed(
|
|
200
|
+
[
|
|
201
|
+
INSTALL,
|
|
202
|
+
'-S',
|
|
203
|
+
'-m',
|
|
204
|
+
mode,
|
|
205
|
+
...(options.uid === undefined ? [] : ['-o', String(options.uid)]),
|
|
206
|
+
...(options.gid === undefined ? [] : ['-g', String(options.gid)]),
|
|
207
|
+
staged.path,
|
|
208
|
+
path,
|
|
209
|
+
],
|
|
210
|
+
staged.path,
|
|
211
|
+
);
|
|
212
|
+
} finally {
|
|
213
|
+
await staged.dispose();
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The real sudo runner: localRunner() for everything, `/usr/bin/sudo -n` for the allowlist.
|
|
221
|
+
* Pass it explicitly: `launchdProviders(sudoRunner({ prefixes: ['/Library/LaunchDaemons'] }))`.
|
|
222
|
+
*/
|
|
223
|
+
export const sudoRunner = (options: SudoRunnerOptions): HostRunner => {
|
|
224
|
+
const base = localRunner(options);
|
|
225
|
+
return makeSudoRunner(options.prefixes, {
|
|
226
|
+
base,
|
|
227
|
+
groups: () => operatorGroups(base),
|
|
228
|
+
log:
|
|
229
|
+
options.log ??
|
|
230
|
+
((line) => {
|
|
231
|
+
process.stderr.write(`${line}\n`);
|
|
232
|
+
}),
|
|
233
|
+
stage: stageFile,
|
|
234
|
+
});
|
|
235
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where sudo-runner.ts puts a file's bytes before `install` copies them into place as root.
|
|
3
|
+
*
|
|
4
|
+
* ★ WHY STAGE AT ALL. The deploying user cannot write inside a root-owned directory, and the bytes
|
|
5
|
+
* must not travel through argv (they would reach the log and `ps`). So they are written, as the
|
|
6
|
+
* deploying user, to a private file, and `install` copies that one file.
|
|
7
|
+
* ⛔ A 0700 DIRECTORY, A 0600 FILE, O_EXCL: between the write and the install, nobody but the
|
|
8
|
+
* deploying user and root can read the bytes or swap the file.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/** A file staged for `install`, and how to remove it again. */
|
|
15
|
+
export type Staged = { readonly path: string; readonly dispose: () => Promise<void> };
|
|
16
|
+
|
|
17
|
+
export const stageFile = async (bytes: Uint8Array): Promise<Staged> => {
|
|
18
|
+
const dir = await mkdtemp(join(tmpdir(), 'hf-sudo-'));
|
|
19
|
+
const dispose = () => rm(dir, { force: true, recursive: true });
|
|
20
|
+
try {
|
|
21
|
+
const path = join(dir, 'staged');
|
|
22
|
+
await writeFile(path, bytes, { flag: 'wx', mode: 0o600 });
|
|
23
|
+
return { dispose, path };
|
|
24
|
+
} catch (cause) {
|
|
25
|
+
await dispose();
|
|
26
|
+
throw cause;
|
|
27
|
+
}
|
|
28
|
+
};
|
package/src/openbao/README.md
CHANGED
|
@@ -14,6 +14,11 @@ plus `FetchHttpClient.layer`.
|
|
|
14
14
|
secret, a CA key, a plugin `env` or an OIDC client secret.
|
|
15
15
|
- Every family defaults to `retain` on destroy. For what a rename does, read
|
|
16
16
|
[REPLACE.md](./REPLACE.md) before you change any resource's path or name.
|
|
17
|
+
- ⛔ A rename or move onto a name or path that already exists fails the plan, before anything is
|
|
18
|
+
written (every family but the mounts, whose moves are below). Move through a free name, in two
|
|
19
|
+
deploys, or remove the target first.
|
|
20
|
+
- ⚠️ A NEW logical id for a live name adopts it, and under `destroy` the old id's delete then
|
|
21
|
+
removes it. Change a logical id with Alchemy's `renamedFrom` (REPLACE.md).
|
|
17
22
|
|
|
18
23
|
## assertBaoIdentity: call it first
|
|
19
24
|
|
|
@@ -70,9 +75,10 @@ export const hostRoles = Effect.gen(function* () {
|
|
|
70
75
|
- A role is named `<class>--<host>`. Class and host names may contain only lowercase letters,
|
|
71
76
|
digits and single hyphens.
|
|
72
77
|
- It refuses `secretIdTtl` 0 (a secret_id that never expires), empty policies, `root`, unknown
|
|
73
|
-
classes and
|
|
78
|
+
classes and a host listed twice in one class. A host in several classes gets one role per class.
|
|
74
79
|
- ⚠️ Renaming a host or class makes a new role. Under `retain` the old one stays live and keeps
|
|
75
|
-
admitting its secret_ids (REPLACE.md).
|
|
80
|
+
admitting its secret_ids (REPLACE.md). Under `destroy` its secret_ids go, but tokens it already
|
|
81
|
+
issued run to their TTL: deleting a role revokes no token.
|
|
76
82
|
|
|
77
83
|
## BaoJwtRole / BaoKubernetesRole / BaoJwtAuthConfig
|
|
78
84
|
|
package/src/openbao/REPLACE.md
CHANGED
|
@@ -4,9 +4,10 @@ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, i
|
|
|
4
4
|
Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
|
|
5
5
|
|
|
6
6
|
★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
|
|
7
|
-
in-memory state store against the fake engines in `fake-engines.ts
|
|
8
|
-
`role-rename.test.ts
|
|
9
|
-
|
|
7
|
+
in-memory state store against the fake engines in `fake-engines.ts` and `fake-engines-roles.ts`.
|
|
8
|
+
`policy-rename.test.ts`, `role-rename.test.ts`, `rename-occupied.test.ts`,
|
|
9
|
+
`rename-families.test.ts`, `rename-identity.test.ts` and `rename-adoption.test.ts` deploy, rename,
|
|
10
|
+
and deploy again, then check the plan and every call that reached the fake.
|
|
10
11
|
|
|
11
12
|
## What Alchemy does with each answer
|
|
12
13
|
|
|
@@ -44,46 +45,60 @@ stayed live. `destroy` could not help, because nothing recorded the old one.
|
|
|
44
45
|
|
|
45
46
|
## Answers, by resource
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
| `Bao.
|
|
55
|
-
| `Bao.
|
|
56
|
-
| `Bao.
|
|
57
|
-
| `Bao.
|
|
58
|
-
| `Bao.
|
|
59
|
-
| `Bao.
|
|
60
|
-
| `Bao.
|
|
48
|
+
⛔ Every family below except `Bao.Mount` and `Bao.AuthMethod` judges its identity **before**
|
|
49
|
+
`isResolved(news)`, and **fails the plan** on a move onto an object that already exists
|
|
50
|
+
(`rename-identity.ts`, below). Names compare as the server keys them: exact, except where a row
|
|
51
|
+
says folded.
|
|
52
|
+
|
|
53
|
+
| Resource | Identity change | Answer now | Other changes |
|
|
54
|
+
| ------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
|
|
56
|
+
| `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
|
|
57
|
+
| `Bao.AuthRole` | `name` (folded) | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. Its delete clears the secret_ids; issued tokens run to their TTL (below). | `update` |
|
|
58
|
+
| `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
|
|
59
|
+
| `Bao.JwtRole` | `mount`, `name` (folded) | `replace`. The name is folded: the server reads it lowercased (TypeLowerCaseString). | `update` (merge write; every managed field is sent) |
|
|
60
|
+
| `Bao.KubernetesRole` | `mount`, `name` (folded) | `replace`. The name is folded, as the server stores it. | `update` |
|
|
61
|
+
| `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. A move onto a mount with a config fails the plan too: it would rewrite that mount's logins. | `update` (full-replace write) |
|
|
62
|
+
| `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret. Reconcile refuses it too, when the name was an Output. | `update` (the upsert keeps the id) |
|
|
63
|
+
| `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
|
|
64
|
+
| `Bao.SshRole` | `mount`, `name` | `replace` | `update` (full-replace write) |
|
|
65
|
+
| `Bao.Plugin` | `type`, `name`, `version` | `replace`: a version bump is a new catalog entry, and `retain` keeps the old version registered. | `update` (full-replace register) |
|
|
66
|
+
| `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. | `update` (fragments digest) |
|
|
67
|
+
| `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. | `update` |
|
|
68
|
+
| `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** if the rename also changes `mintUser`, unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
|
|
61
69
|
|
|
62
70
|
`hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
|
|
63
71
|
renames the role. With the logical id taken from the role name, as the README does, the old id
|
|
64
72
|
leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
|
|
65
|
-
`Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
|
|
73
|
+
`Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live. A
|
|
74
|
+
host may sit in several classes: it gets one role per class, and only the same host twice in one
|
|
75
|
+
class is refused.
|
|
66
76
|
|
|
67
77
|
## When the new identity is not known at plan
|
|
68
78
|
|
|
69
79
|
- **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
|
|
70
|
-
changing).
|
|
71
|
-
|
|
80
|
+
changing). Every family judges the identity props before `isResolved(news)`
|
|
81
|
+
(`rename-identity.ts`), so the rename still plans `replace`. Measured for all twelve. Before
|
|
82
|
+
2026-09-21 the nine batch-2 families planned `update` here and orphaned the old object.
|
|
72
83
|
- **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
|
|
73
84
|
unknown identity, because under `destroy` a replace onto the same path deletes what it just
|
|
74
85
|
wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
|
|
75
86
|
object, **refuses before any write**. The row is left `updating`, with the new props and the
|
|
76
|
-
old attributes. The next deploy sees both names and plans `replace`. Measured
|
|
87
|
+
old attributes. The next deploy sees both names and plans `replace`. Measured for all twelve.
|
|
88
|
+
`Bao.MfaTotpMethod` refuses there with its rename refusal, and its next plan fails the same way.
|
|
77
89
|
- `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
|
|
78
90
|
rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
|
|
79
91
|
|
|
80
92
|
## A move onto an object that already exists fails the plan
|
|
81
93
|
|
|
82
|
-
⛔
|
|
83
|
-
is there. 🔴 Measured before the guard: two policies that swapped names under
|
|
94
|
+
⛔ Every family in the table reads the new path at plan (`judgeMove`, `rename.ts`) and refuses the
|
|
95
|
+
move if anything is there. 🔴 Measured before the guard: two policies that swapped names under
|
|
84
96
|
`RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
|
|
85
97
|
policy, and each old generation's delete then removed the name the other had just written. The
|
|
86
|
-
deploy was green, and both policies were gone, every grant revoked.
|
|
98
|
+
deploy was green, and both policies were gone, every grant revoked. The same swap deleted both
|
|
99
|
+
objects for `Bao.AuthRole`, `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.SshRole` and
|
|
100
|
+
`Bao.Plugin`. `Bao.JwtAuthConfig` (no delete) and `Bao.MfaLoginEnforcement` (delete refused) lost
|
|
101
|
+
nothing, but each new generation silently rewrote the other's live object. Measured the same way:
|
|
87
102
|
|
|
88
103
|
- a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
|
|
89
104
|
stack deleted the name it moved onto;
|
|
@@ -94,10 +109,12 @@ deploy was green, and both policies were gone, every grant revoked. Measured the
|
|
|
94
109
|
|
|
95
110
|
⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
|
|
96
111
|
have been harmless. So would a move back onto a retained old generation. Move in two deploys
|
|
97
|
-
through a name nothing holds, or remove the target by hand first.
|
|
98
|
-
|
|
112
|
+
through a name nothing holds, or remove the target by hand first. For `Bao.Plugin` that includes a
|
|
113
|
+
version bump onto a version already registered by hand. A change of case the server folds (policy,
|
|
114
|
+
AppRole, JWT and Kubernetes role names), or a trailing `/` on a mount, is the same object, so it is
|
|
115
|
+
not a move and is never refused (`rename-identity.test.ts`).
|
|
99
116
|
|
|
100
|
-
## What `retain` means for
|
|
117
|
+
## What `retain` means for batch 3A and AppRole
|
|
101
118
|
|
|
102
119
|
Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
|
|
103
120
|
retained."). It is live and unmanaged from then on:
|
|
@@ -112,13 +129,27 @@ retained."). It is live and unmanaged from then on:
|
|
|
112
129
|
- **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
|
|
113
130
|
plugin's delete leaves outstanding leases revocable (its own
|
|
114
131
|
`TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
|
|
132
|
+
- **`Bao.AuthRole`**: the old role keeps admitting its secret_ids. Under `destroy` the delete
|
|
133
|
+
removes every secret_id and the role_id, so no new login succeeds, but it revokes no token:
|
|
134
|
+
tokens already issued live to their TTL and only fail to renew (openbao v2.6.2 approle
|
|
135
|
+
`pathRoleDelete`, path_role.go:1858-1902; `pathLoginRenew`, path_login.go:414-438). Revoke by
|
|
136
|
+
accessor to contain one.
|
|
115
137
|
|
|
116
138
|
★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
|
|
117
139
|
`declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
|
|
118
140
|
orphan delete, which `retain` also keeps live.
|
|
119
141
|
|
|
120
|
-
|
|
121
|
-
|
|
142
|
+
## A new declaration of a live name: the swap the guard cannot see
|
|
143
|
+
|
|
144
|
+
⚠️ A NEW logical id whose name already exists takes that object over and rewrites it: these
|
|
145
|
+
families' `read` never answers `Unowned`, so Alchemy silently adopts it. Nothing moved, so
|
|
146
|
+
`judgeMove` never runs. 🔴 Measured (`rename-adoption.test.ts`): under `destroy` the old owner's
|
|
147
|
+
delete then runs after the adoption wrote, and removes the object the new id now claims, in a
|
|
148
|
+
green deploy. It happened when a logical id changed with the name kept, to each of the nine
|
|
149
|
+
batch-2 families whose delete runs (a TOTP method and its enrolments included), and when a new
|
|
150
|
+
`Bao.AuthRole` took a name another one moved off in the same deploy. Change a logical id with
|
|
151
|
+
Alchemy's `renamedFrom('<old id>')`, which migrates the state row and plans an `update`. Take a
|
|
152
|
+
freed name in the deploy after the move.
|
|
122
153
|
|
|
123
154
|
## Why the moves fail instead of replacing
|
|
124
155
|
|
|
@@ -152,12 +183,9 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
|
|
|
152
183
|
3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
|
|
153
184
|
it fails on purpose instead (above).
|
|
154
185
|
|
|
155
|
-
##
|
|
186
|
+
## Closed
|
|
156
187
|
|
|
157
|
-
-
|
|
158
|
-
|
|
159
|
-
`Bao.
|
|
160
|
-
|
|
161
|
-
guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
|
|
162
|
-
helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
|
|
163
|
-
reconcile re-plans the move from the stated path.)
|
|
188
|
+
- ✅ **Nine families checked the identity after `isResolved(news)`** and never read the target
|
|
189
|
+
(closed 2026-09-21 by `rename-identity.ts`, measured in `rename-families.test.ts`).
|
|
190
|
+
`Bao.Mount` and `Bao.AuthMethod` never needed it: their reconcile re-plans the move from the
|
|
191
|
+
stated path.
|
|
@@ -5,17 +5,11 @@
|
|
|
5
5
|
* sdk v2.6.2 framework.FieldData.Validate on the approle role fields (path_role.go:166-167,
|
|
6
6
|
* TypeDurationSecond): `secret_id_ttl: "2160h"` validates and parses to 7776000 seconds. A role
|
|
7
7
|
* read hands back that integer, which attributesOf renders as `90d` — the same role.
|
|
8
|
-
*
|
|
8
|
+
* (What the diff calls a rename is pinned in rename-families.test.ts, `foldName`.)
|
|
9
9
|
*/
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { describe, it } from 'node:test';
|
|
12
|
-
import {
|
|
13
|
-
type BaoAuthRoleProps,
|
|
14
|
-
attributesOf,
|
|
15
|
-
isRename,
|
|
16
|
-
matches,
|
|
17
|
-
writeBody,
|
|
18
|
-
} from './auth-role-form.ts';
|
|
12
|
+
import { type BaoAuthRoleProps, attributesOf, matches, writeBody } from './auth-role-form.ts';
|
|
19
13
|
|
|
20
14
|
const PROPS: BaoAuthRoleProps = {
|
|
21
15
|
bindSecretId: true,
|
|
@@ -47,11 +41,3 @@ describe('auth role secret_id TTL', () => {
|
|
|
47
41
|
assert.equal(matches(attributesOf(PROPS, { ...live, secret_id_ttl: 0 }), PROPS), false);
|
|
48
42
|
});
|
|
49
43
|
});
|
|
50
|
-
|
|
51
|
-
describe('auth role rename', () => {
|
|
52
|
-
it('is a rename only when the lower-cased names differ, as the server stores them', () => {
|
|
53
|
-
assert.equal(isRename('pve-node--node-a', 'pve-node--node-b'), true);
|
|
54
|
-
assert.equal(isRename('Host-Cert', 'host-cert'), false);
|
|
55
|
-
assert.equal(isRename('host-cert', 'host-cert'), false);
|
|
56
|
-
});
|
|
57
|
-
});
|
|
@@ -89,17 +89,6 @@ export const writeBody = (props: BaoAuthRoleProps): Record<string, string> => {
|
|
|
89
89
|
return body;
|
|
90
90
|
};
|
|
91
91
|
|
|
92
|
-
/**
|
|
93
|
-
* Whether a changed `name` names a DIFFERENT role — the diff's `replace` question.
|
|
94
|
-
*
|
|
95
|
-
* ⚠️ CASE-INSENSITIVELY, BECAUSE THE SERVER IS. AppRole stores `role/<lowercased name>` (openbao
|
|
96
|
-
* v2.6.2 builtin/credential/approle/path_role.go:1485, and roleEntry reads the same key, :1537),
|
|
97
|
-
* so `Host` → `host` is the SAME role: a `replace` there would write it, then — under `destroy` —
|
|
98
|
-
* delete the old generation, which is that same role.
|
|
99
|
-
*/
|
|
100
|
-
export const isRename = (from: string, to: string): boolean =>
|
|
101
|
-
from.toLowerCase() !== to.toLowerCase();
|
|
102
|
-
|
|
103
92
|
const samePolicies = (want: readonly string[], have: readonly string[]) =>
|
|
104
93
|
want.length === have.length && want.every((policy, index) => policy === have[index]);
|
|
105
94
|
|