@homeflare/alchemy 0.40.0 → 0.42.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/dist/caddy/index.js.map +2 -2
- package/dist/ceph/index.js +2 -1
- package/dist/ceph/index.js.map +3 -3
- package/dist/launchd/fake-runner.d.ts.map +1 -1
- package/dist/launchd/index.d.ts +1 -1
- package/dist/launchd/index.d.ts.map +1 -1
- package/dist/launchd/index.js +7 -1
- package/dist/launchd/index.js.map +5 -5
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +21 -0
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/directory-lifecycle.d.ts.map +1 -1
- package/dist/linux/fake-linux-host.d.ts.map +1 -1
- package/dist/linux/index.d.ts +1 -1
- package/dist/linux/index.d.ts.map +1 -1
- package/dist/linux/index.js +16 -4
- package/dist/linux/index.js.map +7 -7
- package/dist/linux/ssh-runner.d.ts.map +1 -1
- package/dist/linux/sudo-runner.d.ts.map +1 -1
- package/dist/opnsense/alias-form.d.ts +14 -1
- package/dist/opnsense/alias-form.d.ts.map +1 -1
- package/dist/opnsense/alias.d.ts +1 -1
- package/dist/opnsense/alias.d.ts.map +1 -1
- package/dist/opnsense/group-form.d.ts +8 -2
- package/dist/opnsense/group-form.d.ts.map +1 -1
- package/dist/opnsense/group.d.ts +1 -1
- package/dist/opnsense/group.d.ts.map +1 -1
- package/dist/opnsense/index.js +15 -7
- package/dist/opnsense/index.js.map +7 -7
- package/dist/opnsense/wire.d.ts +42 -12
- package/dist/opnsense/wire.d.ts.map +1 -1
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +18 -4
- package/dist/proxmox/credential-errors.d.ts.map +1 -1
- package/dist/proxmox/credentials.d.ts +35 -1
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +81 -51
- package/dist/proxmox/index.js.map +19 -17
- package/dist/proxmox/lease-cache.d.ts +67 -5
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +4 -2
- package/dist/proxmox/mint.d.ts.map +1 -1
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/storage-form.d.ts +28 -3
- package/dist/proxmox/storage-form.d.ts.map +1 -1
- package/dist/proxmox/storage-plugin-options.d.ts +2 -0
- package/dist/proxmox/storage-plugin-options.d.ts.map +1 -0
- package/dist/proxmox/storage-wire.d.ts +28 -1
- package/dist/proxmox/storage-wire.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +6 -1
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +8 -1
- package/dist/proxmox/unreadable-read.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool.d.ts.map +1 -1
- package/dist/release/index.d.ts +1 -1
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js.map +2 -2
- package/docs/credentials.md +14 -1
- package/docs/distilled-interim.md +7 -0
- package/docs/launchd.md +5 -3
- package/docs/provider-standard.md +5 -1
- package/docs/talos-argocd-dogfood.md +160 -0
- package/package.json +2 -2
- package/src/launchd/fake-runner.ts +2 -0
- package/src/launchd/index.ts +8 -1
- package/src/launchd/local-runner.ts +16 -1
- package/src/launchd/runner.ts +22 -0
- package/src/launchd/sudo-runner.ts +3 -0
- package/src/linux/directory-lifecycle.ts +18 -3
- package/src/linux/directory-sudo.test.ts +59 -0
- package/src/linux/directory.test.ts +34 -8
- package/src/linux/fake-linux-host.ts +2 -0
- package/src/linux/index.ts +1 -0
- package/src/linux/ssh-runner.ts +4 -0
- package/src/linux/sudo-runner.ts +3 -0
- package/src/opnsense/alias-form.test.ts +24 -12
- package/src/opnsense/alias-form.ts +20 -7
- package/src/opnsense/alias.test.ts +13 -6
- package/src/opnsense/alias.ts +16 -10
- package/src/opnsense/group-form.test.ts +12 -8
- package/src/opnsense/group-form.ts +13 -4
- package/src/opnsense/group.test.ts +18 -7
- package/src/opnsense/group.ts +10 -3
- package/src/opnsense/wire.ts +57 -12
- package/src/proxmox/acl.ts +7 -1
- package/src/proxmox/api-token.ts +7 -1
- package/src/proxmox/credential-errors.ts +19 -5
- package/src/proxmox/credentials.test.ts +73 -1
- package/src/proxmox/credentials.ts +72 -1
- package/src/proxmox/group.ts +7 -1
- package/src/proxmox/lease-cache-mint.test.ts +70 -0
- package/src/proxmox/lease-cache.test.ts +83 -7
- package/src/proxmox/lease-cache.ts +74 -10
- package/src/proxmox/mint.ts +12 -5
- package/src/proxmox/node-network.ts +7 -1
- package/src/proxmox/role.ts +7 -1
- package/src/proxmox/storage-form.ts +48 -8
- package/src/proxmox/storage-plugin-options.ts +45 -0
- package/src/proxmox/storage-update.test.ts +183 -0
- package/src/proxmox/storage-wire.ts +54 -7
- package/src/proxmox/storage.ts +25 -7
- package/src/proxmox/unreadable-read.ts +16 -4
- package/src/proxmox/user.ts +7 -1
- package/src/proxmox/zfs-pool.ts +7 -1
- package/src/release/index.ts +1 -0
|
@@ -12,6 +12,7 @@ import { spec } from './group.ts';
|
|
|
12
12
|
import { fakeFailure, fakeOpnsense, fakeOpnsenseLayer, getOnlyOrFail } from './fake-opnsense.ts';
|
|
13
13
|
import type { OpnsenseWriteRefused } from './policy.ts';
|
|
14
14
|
import { opnsenseOperations } from './resource.ts';
|
|
15
|
+
import { optionMap } from './wire.ts';
|
|
15
16
|
|
|
16
17
|
const asRefusal = (value: unknown) => value as OpnsenseWriteRefused;
|
|
17
18
|
|
|
@@ -24,14 +25,18 @@ const PROPS: GroupProps = {
|
|
|
24
25
|
uuid: UUID,
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
// `liveItem` builds a `ModelIfgroupentryReadItem` — the WHOLE-MODEL `get()`'s read-shaped item,
|
|
29
|
+
// whose `members` decodes as an option map, not a comma string (OPNSENSE-2 — see group-form.ts).
|
|
30
|
+
const liveItem = (
|
|
31
|
+
overrides: Partial<group.ModelIfgroupentryReadItem> = {},
|
|
32
|
+
): group.ModelIfgroupentryReadItem => ({
|
|
28
33
|
ifname: 'IOT_DEVICES',
|
|
29
|
-
members: 'opt1,opt2',
|
|
34
|
+
members: optionMap('opt1', 'opt2'),
|
|
30
35
|
sequence: '1',
|
|
31
36
|
...overrides,
|
|
32
37
|
});
|
|
33
38
|
|
|
34
|
-
const getResponse = (items: Record<string, group.
|
|
39
|
+
const getResponse = (items: Record<string, group.ModelIfgroupentryReadItem>) =>
|
|
35
40
|
Response.json({ group: { ifgroupentry: items } });
|
|
36
41
|
|
|
37
42
|
describe('Opnsense.Firewall.Group spec.fetchLive', () => {
|
|
@@ -86,17 +91,21 @@ describe('opnsenseOperations(spec).read — S7/S8, marker-less API', () => {
|
|
|
86
91
|
|
|
87
92
|
describe('opnsenseOperations(spec).diff', () => {
|
|
88
93
|
test('noop when the declared member set matches live, regardless of order', async () => {
|
|
89
|
-
const fake = getOnlyOrFail(() =>
|
|
94
|
+
const fake = getOnlyOrFail(() =>
|
|
95
|
+
getResponse({ [UUID]: liveItem({ members: optionMap('opt2', 'opt1') }) }),
|
|
96
|
+
);
|
|
90
97
|
const result = await Effect.runPromise(
|
|
91
98
|
opnsenseOperations(spec)
|
|
92
|
-
.diff(PROPS, attributesOf(UUID, liveItem({ members: 'opt2,opt1' })))
|
|
99
|
+
.diff(PROPS, attributesOf(UUID, liveItem({ members: optionMap('opt2', 'opt1') })))
|
|
93
100
|
.pipe(Effect.provide(fakeOpnsenseLayer(fake.fetch))),
|
|
94
101
|
);
|
|
95
102
|
expect(result).toEqual({ action: 'noop' });
|
|
96
103
|
});
|
|
97
104
|
|
|
98
105
|
test('update when a member was removed live', async () => {
|
|
99
|
-
const fake = getOnlyOrFail(() =>
|
|
106
|
+
const fake = getOnlyOrFail(() =>
|
|
107
|
+
getResponse({ [UUID]: liveItem({ members: optionMap('opt1') }) }),
|
|
108
|
+
);
|
|
100
109
|
const result = await Effect.runPromise(
|
|
101
110
|
opnsenseOperations(spec)
|
|
102
111
|
.diff(PROPS, attributesOf(UUID, liveItem()))
|
|
@@ -108,7 +117,9 @@ describe('opnsenseOperations(spec).diff', () => {
|
|
|
108
117
|
|
|
109
118
|
describe('opnsenseOperations(spec).reconcile and destroy — the read-only policy', () => {
|
|
110
119
|
test('reconcile observes with one GET, then refuses — never a write', async () => {
|
|
111
|
-
const fake = getOnlyOrFail(() =>
|
|
120
|
+
const fake = getOnlyOrFail(() =>
|
|
121
|
+
getResponse({ [UUID]: liveItem({ members: optionMap('opt1') }) }),
|
|
122
|
+
);
|
|
112
123
|
const failure = asRefusal(
|
|
113
124
|
await Effect.runPromise(
|
|
114
125
|
Effect.flip(
|
package/src/opnsense/group.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* `Opnsense.Firewall.Group` — a read-only declaration of one `firewall/group` item (an
|
|
3
3
|
* "Interface Group", not a permission group) on the edge. READ-ONLY BY DESIGN — see policy.ts's
|
|
4
4
|
* header. `reconcile`/`delete` always refuse. Mirrors alias.ts exactly; see that file's header
|
|
5
|
-
* for why `get()` (whole model) is the read source
|
|
6
|
-
*
|
|
5
|
+
* for why `get()` (whole model) is the read source uniformly across this family rather than the
|
|
6
|
+
* now-correct `getGroup` per-item operation (OPNSENSE-1 fixed `GetGroupRequest`'s missing `uuid`,
|
|
7
|
+
* but switching to it is a separate, later PR). `members` decodes as an option map, not a comma
|
|
8
|
+
* string — see `group-form.ts`'s `attributesOf` (OPNSENSE-2).
|
|
7
9
|
*/
|
|
8
10
|
import { adopt } from 'alchemy/AdoptPolicy';
|
|
9
11
|
import { Resource } from 'alchemy';
|
|
@@ -54,7 +56,12 @@ export const isOpnsenseFirewallGroup = (value: unknown): value is OpnsenseFirewa
|
|
|
54
56
|
value !== null &&
|
|
55
57
|
(value as { Type?: unknown }).Type === 'Opnsense.Firewall.Group';
|
|
56
58
|
|
|
57
|
-
export const spec: OpnsenseSpec<
|
|
59
|
+
export const spec: OpnsenseSpec<
|
|
60
|
+
GroupProps,
|
|
61
|
+
Group.ModelIfgroupentryReadItem,
|
|
62
|
+
GroupAttributes,
|
|
63
|
+
OpnsenseOpError
|
|
64
|
+
> = {
|
|
58
65
|
attributes: attributesOf,
|
|
59
66
|
fetchLive: (props) =>
|
|
60
67
|
Group.get({}).pipe(Effect.map((res) => res.group?.ifgroupentry?.[props.uuid])),
|
package/src/opnsense/wire.ts
CHANGED
|
@@ -2,20 +2,27 @@
|
|
|
2
2
|
* How OPNsense's MVC model spells a value on the wire, and how to get it back to something
|
|
3
3
|
* comparable — the `../proxmox/values.ts` idea, scoped to this vendor's own spellings.
|
|
4
4
|
*
|
|
5
|
-
* ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS
|
|
6
|
-
*
|
|
7
|
-
* string
|
|
8
|
-
* `ModelRelationField` is a SINGLE comma-joined string, not a JSON array
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* ★ OPNSENSE HAS EXACTLY TWO WIRE SHAPES THIS FAMILY NEEDS ON WRITE (props stay plain values;
|
|
6
|
+
* this family never writes, but `matches` compares against them). Every `BooleanField` is the
|
|
7
|
+
* string `"1"` or `"0"` (never a JSON boolean). Every multi-select `OptionField`/
|
|
8
|
+
* `ModelRelationField`'s WRITE-side spelling is a SINGLE comma-joined string, not a JSON array.
|
|
9
|
+
*
|
|
10
|
+
* ⚠️ READ IS A THIRD SHAPE (OPNSENSE-2, distilled `homeflare/opnsense`'s `read-shape.ts`): the
|
|
11
|
+
* SAME list-shaped fields decode on GET as an option map `{key: {value, selected}}`, not a
|
|
12
|
+
* string — `AliasItem`/`GroupItem` (this family's OLD, pre-fix type references) were the
|
|
13
|
+
* WRITE-shaped structs; `fetchLive` actually decodes `ModelAliasReadItem`/
|
|
14
|
+
* `ModelIfgroupentryReadItem` (the WHOLE-MODEL GET's read-shaped item), whose `type`/
|
|
15
|
+
* `interface`/`proto`/`categories`/`members` are option maps. `selectedOf`/`selectedOneOf`
|
|
16
|
+
* below extract the selected key(s) back to the SAME plain-string/string[] shape `matches`
|
|
17
|
+
* already compares, so the rest of this family's logic (and `AliasAttributes`/
|
|
18
|
+
* `GroupAttributes`'s own field types) is unchanged.
|
|
11
19
|
*
|
|
12
20
|
* ⚠️ COMMA-LIST ORDER ROUND-TRIP IS NOT ASSUMED STABLE. OPNsense builds these from a PHP
|
|
13
|
-
* associative array
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `groups` field.
|
|
21
|
+
* associative array, and nothing in the model XML promises the join order survives a
|
|
22
|
+
* save-then-read unchanged. Sorting and deduplicating both sides before comparing is what makes
|
|
23
|
+
* `matches` (alias-form.ts, group-form.ts) actually settle instead of reporting drift on a
|
|
24
|
+
* re-ordered list that changed nothing — the same reasoning `../proxmox/user-wire.ts`'s
|
|
25
|
+
* `groupSet` documents for PVE's `groups` field.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
28
|
/** OPNsense's own boolean spelling. Absent takes the caller's stated default — OPNsense's own
|
|
@@ -54,3 +61,41 @@ export const numField = (value: string | undefined, fallback: number): number =>
|
|
|
54
61
|
const parsed = Number(value);
|
|
55
62
|
return Number.isFinite(parsed) ? parsed : fallback;
|
|
56
63
|
};
|
|
64
|
+
|
|
65
|
+
/** One entry of a GET-decoded option map — `{value: <label>, selected: 0|1}`, see this file's
|
|
66
|
+
* module doc. Structural, not imported from the SDK: every generated `*Map` field shares this
|
|
67
|
+
* exact shape (`OptionEntry` in each service module), and staying structural here avoids a
|
|
68
|
+
* per-resource-file type import just for this. */
|
|
69
|
+
export interface OpnsenseOptionEntry {
|
|
70
|
+
readonly value: string;
|
|
71
|
+
readonly selected: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The selected key(s) of a GET-decoded option map, normalised the same way `csvSet` normalises
|
|
76
|
+
* the write-side comma string — sorted, deduplicated, empty-key filtered. Use for a
|
|
77
|
+
* `Multiple="Y"` field (`AliasItem.proto`, `AliasItem.categories`, `GroupItem.members`); for a
|
|
78
|
+
* single-select field use `selectedOneOf`.
|
|
79
|
+
*/
|
|
80
|
+
export const selectedOf = (
|
|
81
|
+
map: Readonly<Record<string, OpnsenseOptionEntry | undefined>> | undefined,
|
|
82
|
+
): readonly string[] => {
|
|
83
|
+
if (map === undefined) return [];
|
|
84
|
+
const keys = Object.entries(map)
|
|
85
|
+
.filter(([key, entry]) => key !== '' && entry?.selected === 1)
|
|
86
|
+
.map(([key]) => key);
|
|
87
|
+
return [...new Set(keys)].sort();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Single-select convenience over `selectedOf` — the one selected key, or `''` if none (the same
|
|
91
|
+
* "absent" spelling a plain string field uses, e.g. `AliasItem.type`/`GroupItem`'s own
|
|
92
|
+
* `.\InterfaceField` members when not `Multiple="Y"`). */
|
|
93
|
+
export const selectedOneOf = (
|
|
94
|
+
map: Readonly<Record<string, OpnsenseOptionEntry | undefined>> | undefined,
|
|
95
|
+
): string => selectedOf(map)[0] ?? '';
|
|
96
|
+
|
|
97
|
+
/** The encode direction, for test fixtures: every listed key marked `selected: 1`, mirroring a
|
|
98
|
+
* real OPNsense GET response (`{[key]: {value, selected: 1}}`) — so a fixture exercises the exact
|
|
99
|
+
* decode path `selectedOf`/`selectedOneOf` handle, not the pre-fix plain-string shape. */
|
|
100
|
+
export const optionMap = (...keys: readonly string[]): Record<string, OpnsenseOptionEntry> =>
|
|
101
|
+
Object.fromEntries(keys.map((key) => [key, { value: key, selected: 1 }]));
|
package/src/proxmox/acl.ts
CHANGED
|
@@ -50,6 +50,7 @@ import * as Effect from 'effect/Effect';
|
|
|
50
50
|
import { attributesOf, bind, guardWrite, identity, matches, tuple } from './acl-wire.ts';
|
|
51
51
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
52
52
|
import { runPve } from './distilled-pve.ts';
|
|
53
|
+
import { mintTier } from './credentials.ts';
|
|
53
54
|
import {
|
|
54
55
|
UNREADABLE,
|
|
55
56
|
type Unreadable,
|
|
@@ -148,7 +149,12 @@ export const ProxmoxAclProvider = () =>
|
|
|
148
149
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and
|
|
149
150
|
// force `update` on a grant that was plainly there — see unreadable-read.ts.
|
|
150
151
|
if (live === UNREADABLE) {
|
|
151
|
-
yield* unreadableWarning(
|
|
152
|
+
yield* unreadableWarning(
|
|
153
|
+
'Proxmox.Acl',
|
|
154
|
+
identity(news),
|
|
155
|
+
news.target.mount,
|
|
156
|
+
mintTier(news.target, 'provision'),
|
|
157
|
+
);
|
|
152
158
|
return { action: 'noop' } as const;
|
|
153
159
|
}
|
|
154
160
|
return live !== undefined && matches(live, news)
|
package/src/proxmox/api-token.ts
CHANGED
|
@@ -52,6 +52,7 @@ import * as Provider from 'alchemy/Provider';
|
|
|
52
52
|
import * as access from '@distilled.cloud/proxmox/access';
|
|
53
53
|
import * as Effect from 'effect/Effect';
|
|
54
54
|
import { API_TOKEN_UPDATE, attributesOf, matches, shape } from './api-token-form.ts';
|
|
55
|
+
import { mintTier } from './credentials.ts';
|
|
55
56
|
import { guardWrite } from './distilled-guard.ts';
|
|
56
57
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
57
58
|
import { runPve } from './distilled-pve.ts';
|
|
@@ -190,7 +191,12 @@ export const ProxmoxApiTokenProvider = () =>
|
|
|
190
191
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
191
192
|
// `update` on a token that was plainly there — see unreadable-read.ts.
|
|
192
193
|
if (live === UNREADABLE) {
|
|
193
|
-
yield* unreadableWarning(
|
|
194
|
+
yield* unreadableWarning(
|
|
195
|
+
'Proxmox.ApiToken',
|
|
196
|
+
`${news.userid}!${news.tokenid}`,
|
|
197
|
+
news.target.mount,
|
|
198
|
+
mintTier(news.target, 'provision'),
|
|
199
|
+
);
|
|
194
200
|
return { action: 'noop' } as const;
|
|
195
201
|
}
|
|
196
202
|
// ⚠️ NO GUARDED-CREATE BRANCH HERE, UNLIKE EVERY OTHER FAMILY IN THIS SUB-AREA. There
|
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
* 250-line cap once this class grew a real header (2026-09-24, the cries-wolf fix).
|
|
4
4
|
*/
|
|
5
5
|
import * as Data from 'effect/Data';
|
|
6
|
-
import type { PveRole } from './credentials.ts';
|
|
7
6
|
|
|
8
7
|
/**
|
|
9
|
-
* OpenBao answered 403 to `GET /v1/<mount>/creds/<
|
|
10
|
-
* for `
|
|
8
|
+
* OpenBao answered 403 to `GET /v1/<mount>/creds/<tier>`: this identity's AppRole has no grant
|
|
9
|
+
* for `tier` on `mount` — measured 2026-09-24 as "the agent lane cannot mint `provision`" (the
|
|
11
10
|
* lane's policy grants `read` but not `provision`). A TYPED tag, not the plain `Error` every
|
|
12
11
|
* other mint failure still is (404 no such role/mount, 503 sealed, a network failure), because
|
|
13
12
|
* this ONE case means "refused, not evidence of anything about the object" — a family's
|
|
@@ -18,15 +17,30 @@ import type { PveRole } from './credentials.ts';
|
|
|
18
17
|
* BROKEN, not this identity being narrowly scoped — folding those into "unreadable, report
|
|
19
18
|
* noop" would hide a real outage behind a quiet, misleadingly reassuring plan. Only 403 is
|
|
20
19
|
* "this identity, this role, denied" and safe to read as "cannot see, not evidence of absence".
|
|
20
|
+
*
|
|
21
|
+
* ⛔ NO `role` FIELD (removed, K-T1 second red-team pass). `mint.ts` knows the semantic `PveRole`
|
|
22
|
+
* it was asked for, but `lease-cache.ts`'s synthetic `mintForKey` reconstruction (after M3
|
|
23
|
+
* dropped `role` from `LeaseKey`) cannot supply the ORIGINAL caller's role on a cache-path
|
|
24
|
+
* mint — only the tier. A `role` field would then read as `'read'` on every pve cache miss
|
|
25
|
+
* regardless of which role was actually denied, which is exactly the stale/misleading-field
|
|
26
|
+
* trap M6 existed to close, one layer down. `tier` alone is what the message reads and what
|
|
27
|
+
* `readOrUnreadable`'s callers act on; nothing needs the semantic role name.
|
|
21
28
|
*/
|
|
22
29
|
export class PveCredentialDenied extends Data.TaggedError('PveCredentialDenied')<{
|
|
23
30
|
readonly mount: string;
|
|
24
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The OpenBao mint TIER actually requested (`credentials.ts`'s `mintTier`). ⛔ THE MESSAGE
|
|
33
|
+
* READS THIS, NOT A SEMANTIC ROLE NAME: printing `'read'` for a denied `talos-provision`
|
|
34
|
+
* mint would spell it as `creds/read`, exactly the path that was never called — silently
|
|
35
|
+
* wrong at precisely the moment an operator is reading this string to decide whether a
|
|
36
|
+
* grant, a policy or a target config is wrong (K-T1's own PR discussion).
|
|
37
|
+
*/
|
|
38
|
+
readonly tier: string;
|
|
25
39
|
readonly detail: string;
|
|
26
40
|
}> {
|
|
27
41
|
override get message(): string {
|
|
28
42
|
return (
|
|
29
|
-
`OpenBao GET /v1/${this.mount}/creds/${this.
|
|
43
|
+
`OpenBao GET /v1/${this.mount}/creds/${this.tier} -> 403: ${this.detail}. This identity's ` +
|
|
30
44
|
'AppRole has no grant for this role -- a refused read, not evidence the object is gone.'
|
|
31
45
|
);
|
|
32
46
|
}
|
|
@@ -12,7 +12,7 @@ import { join } from 'node:path';
|
|
|
12
12
|
import { describe, it } from 'node:test';
|
|
13
13
|
import * as Effect from 'effect/Effect';
|
|
14
14
|
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
15
|
-
import type
|
|
15
|
+
import { type BaoEnvironment, type PbsTarget, type PveTarget, mintTier } from './credentials.ts';
|
|
16
16
|
import { mint } from './mint.ts';
|
|
17
17
|
|
|
18
18
|
/**
|
|
@@ -165,4 +165,76 @@ describe('mint', () => {
|
|
|
165
165
|
);
|
|
166
166
|
rmSync(socket, { force: true });
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
// ⛔ K-T1: the whole point of `target.roles` is that a real HTTP request goes to the
|
|
170
|
+
// OVERRIDDEN tier's path, not the bare role's — checked end to end here, not only through
|
|
171
|
+
// `mintTier`'s own unit tests below.
|
|
172
|
+
it('requests the overridden tier, not the bare role, when target.roles names one', async () => {
|
|
173
|
+
const scoped: PveTarget = { ...C1, roles: { read: 'talos-provision' } };
|
|
174
|
+
await withFake(CREDENTIAL, async (address, seen) => {
|
|
175
|
+
await Effect.runPromise(
|
|
176
|
+
mint(scoped, 'read', { BAO_ADDR: address, BAO_TOKEN: TOKEN }).pipe(
|
|
177
|
+
Effect.provide(FetchHttpClient.layer),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
assert.equal(seen[0]?.path, '/v1/proxmox-c1/creds/talos-provision');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ⛔ A role `target.roles` does not mention falls back to itself — an override on `read` alone
|
|
185
|
+
// must never touch `provision` on the same target.
|
|
186
|
+
it('leaves an unmentioned role alone on a partially overridden target', async () => {
|
|
187
|
+
const scoped: PveTarget = { ...C1, roles: { read: 'talos-provision' } };
|
|
188
|
+
await withFake(CREDENTIAL, async (address, seen) => {
|
|
189
|
+
await Effect.runPromise(
|
|
190
|
+
mint(scoped, 'provision', { BAO_ADDR: address, BAO_TOKEN: TOKEN }).pipe(
|
|
191
|
+
Effect.provide(FetchHttpClient.layer),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
assert.equal(seen[0]?.path, '/v1/proxmox-c1/creds/provision');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe('mintTier', () => {
|
|
200
|
+
it('is the bare role when the target carries no override', () => {
|
|
201
|
+
assert.equal(mintTier(C1, 'read'), 'read');
|
|
202
|
+
assert.equal(mintTier(C1, 'provision'), 'provision');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('reads a PveTarget.roles override when one names the role', () => {
|
|
206
|
+
const scoped: PveTarget = { ...C1, roles: { provision: 'talos-provision' } };
|
|
207
|
+
assert.equal(mintTier(scoped, 'provision'), 'talos-provision');
|
|
208
|
+
assert.equal(mintTier(scoped, 'read'), 'read', 'unmentioned role: unchanged');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ⛔ PbsTarget carries no `roles` field at all (credentials.ts's own ⛔) — this pins that a PBS
|
|
212
|
+
// target can never override, structurally, not just "nobody has set one yet".
|
|
213
|
+
it('never overrides a PbsTarget: the field does not exist on that type', () => {
|
|
214
|
+
const pbs: PbsTarget = {
|
|
215
|
+
api: 'https://pbs.example:8007/api2/json',
|
|
216
|
+
mount: 'pbs-c1',
|
|
217
|
+
scheme: 'pbs',
|
|
218
|
+
};
|
|
219
|
+
assert.equal(mintTier(pbs, 'read'), 'read');
|
|
220
|
+
assert.equal(mintTier(pbs, 'provision'), 'provision');
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// K-T1/M5 (second red-team pass): an override that resolves to a DIFFERENT OpenBao path than
|
|
224
|
+
// the one it names must throw, not silently mint a wider or nonexistent tier. Each of these
|
|
225
|
+
// reaches a different path than `<mount>/creds/<tier>` while containing no literal `/`.
|
|
226
|
+
const invalidTiers: Array<[tier: string, reason: string]> = [
|
|
227
|
+
['', 'an empty override falls back to the bare role rather than minting <mount>/creds/'],
|
|
228
|
+
['..', 'a parent-path segment escapes the creds/ prefix entirely'],
|
|
229
|
+
['.', 'a same-directory segment is not a real tier name'],
|
|
230
|
+
['?x=y', 'a querystring is appended instead of naming a tier'],
|
|
231
|
+
['#frag', 'a fragment is appended instead of naming a tier'],
|
|
232
|
+
['a/b', 'a literal slash inserts a second path segment'],
|
|
233
|
+
];
|
|
234
|
+
for (const [bad, reason] of invalidTiers) {
|
|
235
|
+
it(`rejects ${JSON.stringify(bad)} as a tier name: ${reason}`, () => {
|
|
236
|
+
const scoped: PveTarget = { ...C1, roles: { provision: bad } };
|
|
237
|
+
assert.throws(() => mintTier(scoped, 'provision'), /not a valid OpenBao tier name/);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
168
240
|
});
|
|
@@ -86,9 +86,42 @@ export type PveTarget = {
|
|
|
86
86
|
* and a wrong guess fails closed with a 401 rather than doing something odd.
|
|
87
87
|
*/
|
|
88
88
|
readonly scheme: 'pve';
|
|
89
|
+
/**
|
|
90
|
+
* Overrides which OpenBao mint TIER NAME (`<mount>/creds/<tier>`) a semantic role resolves to.
|
|
91
|
+
* Omitted or missing a role: unchanged, `role` is also the tier name, as every consumer before
|
|
92
|
+
* this field got. Present: `mintTier` below reads it first.
|
|
93
|
+
*
|
|
94
|
+
* ★ WHY A TARGET-LEVEL OVERRIDE, NOT A THIRD `PveRole`. A consumer whose OpenBao mount vends a
|
|
95
|
+
* single least-privilege tier for BOTH read and write (one tier that can only ever reach the
|
|
96
|
+
* scope that consumer needs, rather than the estate's general `read`/`provision` split) sets
|
|
97
|
+
* `roles: { read: 'my-tier', provision: 'my-tier' }` and every read/diff/reconcile/delete this
|
|
98
|
+
* package already issues resolves to that tier, with no new call site anywhere in the package.
|
|
99
|
+
* Widening `PveRole` itself to a per-consumer string would instead touch every literal
|
|
100
|
+
* `'read'`/`'provision'` call site across the package (`client.ts`, `lxc-read.ts`,
|
|
101
|
+
* `lxc-task.ts`, `network-apply-read.ts`, `network-apply-run.ts`, `qemu-read.ts`,
|
|
102
|
+
* `qemu-task.ts`, `storage-download-task.ts` — the eight this field's `mintTier` indirection
|
|
103
|
+
* above already covers) for something only the TARGET, never the CALL, needs to know.
|
|
104
|
+
*
|
|
105
|
+
* ⛔ SET BOTH ROLES OR NEITHER (I2, red team on K-T1). An unmentioned role is NOT scoped down —
|
|
106
|
+
* it still mints the estate-wide tier this file's own header describes (`hf-provision@pve`,
|
|
107
|
+
* 27 privileges including `Permissions.Modify`/`Sys.Modify` for `provision`). Setting only
|
|
108
|
+
* `roles: { read: 'my-tier' }` leaves every reconcile/delete on this target minting that wide
|
|
109
|
+
* credential while the plan output looks fully scoped down — nothing here warns. See
|
|
110
|
+
* `docs/credentials.md`'s override paragraph for the same warning at the consumer-facing level.
|
|
111
|
+
*/
|
|
112
|
+
readonly roles?: {
|
|
113
|
+
readonly read?: string;
|
|
114
|
+
readonly provision?: string;
|
|
115
|
+
};
|
|
89
116
|
};
|
|
90
117
|
|
|
91
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* The same client against a Proxmox Backup Server. See the ⛔ on `scheme` above.
|
|
120
|
+
* ⛔ NO `roles` FIELD. The estate has no PBS consumer with a scoped tier yet, and a PBS mount
|
|
121
|
+
* vending only `read`/`provision` is the same shape `mint.ts` always assumed — `mintTier` below
|
|
122
|
+
* never overrides a PBS target for exactly that reason. Add the field here, the same way, when
|
|
123
|
+
* a PBS consumer needs it.
|
|
124
|
+
*/
|
|
92
125
|
export type PbsTarget = {
|
|
93
126
|
readonly mount: string;
|
|
94
127
|
readonly api: string;
|
|
@@ -98,6 +131,44 @@ export type PbsTarget = {
|
|
|
98
131
|
/** Either product. What `pve()` and `pveOperations` accept; what a RESOURCE accepts is narrower. */
|
|
99
132
|
export type ApiTarget = PbsTarget | PveTarget;
|
|
100
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The OpenBao mint tier a role resolves to for `target` — `target.roles`'s override (PVE only)
|
|
136
|
+
* or `role` itself, unchanged. THE single place `<mount>/creds/<tier>` gets its tier segment from
|
|
137
|
+
* a `PveRole`: `mint.ts`'s path and `lease-cache.ts`'s cache key both call this rather than either
|
|
138
|
+
* reading `role` or `target.roles` directly, so there is exactly one definition of "the tier" for
|
|
139
|
+
* both to agree on — `lease-cache.ts`'s own header says why the cache needed this too, not just
|
|
140
|
+
* the mint call.
|
|
141
|
+
*
|
|
142
|
+
* ⛔ AN OVERRIDE THAT IS NOT A PLAIN TIER NAME THROWS RATHER THAN BUILDING A PATH (M5, red team on
|
|
143
|
+
* K-T1; widened after a second red-team pass found the first fix incomplete). `role` itself is
|
|
144
|
+
* a compile-time literal and never needs this check; only `target.roles`, a plain string a
|
|
145
|
+
* caller can set to anything, does. This is an ALLOWLIST, not a denylist of `/` alone — the
|
|
146
|
+
* value is concatenated straight into `${mount}/creds/${tier}` and then into a URL (`mint.ts`),
|
|
147
|
+
* so `''`, `'..'`, `'?x'` and `'#x'` are each their own way to land on a DIFFERENT OpenBao path
|
|
148
|
+
* (a dropped segment, a parent path, a querystring, a fragment) without containing `/` at all.
|
|
149
|
+
* Every one of those produces a 404 `mint.ts`'s own `refuse` reports as a mint failure, and
|
|
150
|
+
* every `pveOperations` family folds a mint failure into "absent" (`unreadable-read.ts`'s ⛔),
|
|
151
|
+
* so the plan would say CREATE for an object that is plainly there instead of naming the bad
|
|
152
|
+
* config — the exact hazard the first version of this check believed it had closed. `role` is
|
|
153
|
+
* source-controlled and this fires at the first `mintTier` call, before any network request, so
|
|
154
|
+
* the fix cannot itself widen what an operator's config can reach.
|
|
155
|
+
*/
|
|
156
|
+
const VALID_TIER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
157
|
+
|
|
158
|
+
export const mintTier = (target: ApiTarget, role: PveRole): string => {
|
|
159
|
+
if (target.scheme !== 'pve') return role;
|
|
160
|
+
const tier = target.roles?.[role];
|
|
161
|
+
if (tier === undefined) return role;
|
|
162
|
+
if (!VALID_TIER.test(tier)) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`PveTarget.roles.${role} = ${JSON.stringify(tier)} is not a valid OpenBao tier name -- ` +
|
|
165
|
+
'it would be interpolated straight into "<mount>/creds/<tier>" and must be a plain name ' +
|
|
166
|
+
'(letters, digits, ".", "_", "-", not starting with one of those and not "..").',
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return tier;
|
|
170
|
+
};
|
|
171
|
+
|
|
101
172
|
/**
|
|
102
173
|
* What a mint returns.
|
|
103
174
|
*
|
package/src/proxmox/group.ts
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
} from './group-wire.ts';
|
|
86
86
|
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
87
87
|
import { runPve } from './distilled-pve.ts';
|
|
88
|
+
import { mintTier } from './credentials.ts';
|
|
88
89
|
import { UNREADABLE, unreadableWarning } from './unreadable-read.ts';
|
|
89
90
|
|
|
90
91
|
export interface GroupProps extends WithTarget {
|
|
@@ -162,7 +163,12 @@ export const ProxmoxGroupProvider = () =>
|
|
|
162
163
|
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
163
164
|
// `update` on a group that was plainly there — see unreadable-read.ts.
|
|
164
165
|
if (live === UNREADABLE) {
|
|
165
|
-
yield* unreadableWarning(
|
|
166
|
+
yield* unreadableWarning(
|
|
167
|
+
'Proxmox.Group',
|
|
168
|
+
news.groupid,
|
|
169
|
+
news.target.mount,
|
|
170
|
+
mintTier(news.target, 'read'),
|
|
171
|
+
);
|
|
166
172
|
return { action: 'noop' } as const;
|
|
167
173
|
}
|
|
168
174
|
if (live === undefined) {
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ⚠️ EXERCISES `mintForKey` ITSELF, NOT A HAND-REPRODUCTION OF ITS CHOICES (second red-team pass
|
|
3
|
+
* on K-T1). `lease-cache.test.ts`'s `targetForKey` tests call `mintTier(targetForKey(key),
|
|
4
|
+
* 'read')` — passing `'read'` as a LITERAL that happens to match `mintForKey`'s own hardcoded
|
|
5
|
+
* pve role, so those tests would keep passing even if `mintForKey` minted with a different role
|
|
6
|
+
* than it reconstructs the target for. A fake OpenBao server pins the ACTUAL request path
|
|
7
|
+
* `mintForKey` sends.
|
|
8
|
+
*
|
|
9
|
+
* Split out of lease-cache.test.ts (2026-09-27) to keep that file under the 250-line cap once this
|
|
10
|
+
* describe block needed its own fake-server helper and imports — same reasoning as
|
|
11
|
+
* tests/apply-main-ruleset-update.test.ts.
|
|
12
|
+
*/
|
|
13
|
+
import assert from 'node:assert/strict';
|
|
14
|
+
import { describe, it } from 'node:test';
|
|
15
|
+
import * as Effect from 'effect/Effect';
|
|
16
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
17
|
+
import { type BaoEnvironment } from './credentials.ts';
|
|
18
|
+
import { type LeaseKey, mintForKey } from './lease-cache.ts';
|
|
19
|
+
|
|
20
|
+
/** ⚠️ Same minimal Bun typing as credentials.test.ts — this package's tsconfig has no Bun types. */
|
|
21
|
+
type ServeOptions = { fetch(request: Request): Promise<Response>; hostname: string; port: number };
|
|
22
|
+
const { Bun } = globalThis as unknown as {
|
|
23
|
+
Bun: { serve(options: ServeOptions): { url: URL; stop(closeActive: boolean): void } };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("K-T1/M3: mintForKey requests the key's own tier, not a re-derived role", () => {
|
|
27
|
+
const withFakeBao = async (body: (address: string, seen: string[]) => Promise<void>) => {
|
|
28
|
+
const seen: string[] = [];
|
|
29
|
+
const server = Bun.serve({
|
|
30
|
+
fetch: async (request: Request) => {
|
|
31
|
+
seen.push(new URL(request.url).pathname);
|
|
32
|
+
return Response.json({ data: { secret: 'x', token_id: 'hf-read@pve!hf-read-1' } });
|
|
33
|
+
},
|
|
34
|
+
hostname: '127.0.0.1',
|
|
35
|
+
port: 0,
|
|
36
|
+
});
|
|
37
|
+
try {
|
|
38
|
+
await body(server.url.origin, seen);
|
|
39
|
+
} finally {
|
|
40
|
+
server.stop(true);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
const env = (address: string): BaoEnvironment => ({ BAO_ADDR: address, BAO_TOKEN: 't' });
|
|
44
|
+
const run = (key: LeaseKey, address: string) =>
|
|
45
|
+
Effect.runPromise(mintForKey(key, env(address)).pipe(Effect.provide(FetchHttpClient.layer)));
|
|
46
|
+
|
|
47
|
+
it('a plain pve key mints /creds/<tier> with the bare role tier', async () => {
|
|
48
|
+
await withFakeBao(async (address, seen) => {
|
|
49
|
+
const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'read' };
|
|
50
|
+
await run(key, address);
|
|
51
|
+
assert.equal(seen[0], '/v1/proxmox-tb4/creds/read');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('an overridden pve key mints the OVERRIDDEN tier, not a bare role', async () => {
|
|
56
|
+
await withFakeBao(async (address, seen) => {
|
|
57
|
+
const key: LeaseKey = { mount: 'proxmox-tb4', scheme: 'pve', tier: 'talos-provision' };
|
|
58
|
+
await run(key, address);
|
|
59
|
+
assert.equal(seen[0], '/v1/proxmox-tb4/creds/talos-provision');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('a pbs key mints its own tier directly', async () => {
|
|
64
|
+
await withFakeBao(async (address, seen) => {
|
|
65
|
+
const key: LeaseKey = { mount: 'pbs-c1', scheme: 'pbs', tier: 'provision' };
|
|
66
|
+
await run(key, address);
|
|
67
|
+
assert.equal(seen[0], '/v1/pbs-c1/creds/provision');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|