@homeflare/alchemy 0.31.0 → 0.31.1
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/proxmox/acl-wire.d.ts +26 -0
- package/dist/proxmox/acl-wire.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +28 -26
- package/dist/proxmox/api-token-form.d.ts.map +1 -1
- package/dist/proxmox/api-token.d.ts +48 -89
- package/dist/proxmox/api-token.d.ts.map +1 -1
- package/dist/proxmox/ceph-fs-wire.d.ts +2 -2
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts +1 -1
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -1
- package/dist/proxmox/ceph-osd-write.d.ts +2 -2
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts +1 -1
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -1
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/client.d.ts.map +1 -1
- package/dist/proxmox/credential-errors.d.ts +27 -0
- package/dist/proxmox/credential-errors.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +0 -38
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/distilled-guard.d.ts +31 -0
- package/dist/proxmox/distilled-guard.d.ts.map +1 -0
- package/dist/proxmox/distilled-pve.d.ts +11 -4
- package/dist/proxmox/distilled-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/group-wire.d.ts +29 -0
- package/dist/proxmox/group-wire.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +20 -24
- package/dist/proxmox/group.d.ts.map +1 -1
- package/dist/proxmox/index.js +725 -509
- package/dist/proxmox/index.js.map +40 -33
- package/dist/proxmox/lease-cache.d.ts +1 -1
- package/dist/proxmox/lease-cache.d.ts.map +1 -1
- package/dist/proxmox/lxc-lifecycle.d.ts +5 -5
- package/dist/proxmox/lxc-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/mint.d.ts +50 -0
- package/dist/proxmox/mint.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +2 -2
- package/dist/proxmox/network-apply-read.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job-form.d.ts +2 -2
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +4 -4
- package/dist/proxmox/role-wire.d.ts +28 -0
- package/dist/proxmox/role-wire.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +36 -52
- package/dist/proxmox/role.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -1
- package/dist/proxmox/unreadable-read.d.ts +50 -0
- package/dist/proxmox/unreadable-read.d.ts.map +1 -0
- package/dist/proxmox/user-wire.d.ts +18 -0
- package/dist/proxmox/user-wire.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +25 -32
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +2 -2
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/proxmox/acl-wire.ts +83 -0
- package/src/proxmox/acl.ts +29 -82
- package/src/proxmox/adopt-noop.test.ts +18 -0
- package/src/proxmox/api-token-adopt.test.ts +28 -12
- package/src/proxmox/api-token-form.ts +50 -93
- package/src/proxmox/api-token.ts +149 -135
- package/src/proxmox/client.ts +2 -7
- package/src/proxmox/constraints-forms.test.ts +7 -27
- package/src/proxmox/credential-errors.ts +33 -0
- package/src/proxmox/credentials.test.ts +29 -3
- package/src/proxmox/credentials.ts +4 -134
- package/src/proxmox/distilled-guard.ts +42 -0
- package/src/proxmox/distilled-pve.ts +15 -4
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -4
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/group-wire.ts +63 -0
- package/src/proxmox/group.test.ts +110 -0
- package/src/proxmox/group.ts +135 -105
- package/src/proxmox/lease-cache.ts +2 -1
- package/src/proxmox/lxc-lifecycle.ts +2 -1
- package/src/proxmox/mint.ts +158 -0
- package/src/proxmox/network-apply.ts +1 -1
- package/src/proxmox/provision-cli-fake.ts +11 -0
- package/src/proxmox/provision-declare.test.ts +6 -1
- package/src/proxmox/role-wire.ts +49 -0
- package/src/proxmox/role.test.ts +89 -0
- package/src/proxmox/role.ts +147 -106
- package/src/proxmox/unreadable-read.test.ts +83 -0
- package/src/proxmox/unreadable-read.ts +60 -0
- package/src/proxmox/user-wire.ts +112 -0
- package/src/proxmox/user.test.ts +142 -0
- package/src/proxmox/user.ts +132 -129
package/src/proxmox/api-token.ts
CHANGED
|
@@ -2,17 +2,15 @@
|
|
|
2
2
|
* `Proxmox.ApiToken` — a PVE API token: the thing a service actually authenticates as.
|
|
3
3
|
*
|
|
4
4
|
* ⛔ THE SECRET EXISTS FOR ONE HTTP RESPONSE AND THEN NOWHERE. MEASURED from the cluster's own
|
|
5
|
-
* schema
|
|
6
|
-
*
|
|
7
|
-
* "
|
|
8
|
-
*
|
|
9
|
-
* `GET /access/users/metrics@pve/token/exporter` answers `{"expire":0,"privsep":0}`. PVE says
|
|
10
|
-
* it plainly on the POST: the value "needs to be stored as it cannot be retrieved afterwards".
|
|
5
|
+
* schema: POST returns `["full-tokenid","info","value"]`; PUT returns `["comment","expire",
|
|
6
|
+
* "full-tokenid","privsep","value"]` with `value` present ONLY when `regenerate` was set; GET
|
|
7
|
+
* returns `["comment","expire","privsep"]` and never the secret. PVE says it plainly on the
|
|
8
|
+
* POST: the value "needs to be stored as it cannot be retrieved afterwards".
|
|
11
9
|
*
|
|
12
10
|
* ⛔ SO A TOKEN THIS RESOURCE CREATES IS UNUSABLE, AND SAYING SO IS THE POINT OF THIS BLOCK. The
|
|
13
11
|
* secret may not become an attribute: Alchemy persists attributes UNENCRYPTED, and this estate's
|
|
14
|
-
* state store is a Postgres dumped nightly (the ⛔ in credentials.ts). So `reconcile`
|
|
15
|
-
*
|
|
12
|
+
* state store is a Postgres dumped nightly (the ⛔ in credentials.ts). So `reconcile` refuses
|
|
13
|
+
* before any POST, and what would land is a live credential in `/etc/pve/user.cfg` whose secret
|
|
16
14
|
* nobody holds. No `regenerate` rescues it, because a regenerated value is discarded the same
|
|
17
15
|
* way. The only escape is an operator who captured the value out of band — which this provider
|
|
18
16
|
* gives them no way to do.
|
|
@@ -20,50 +18,49 @@
|
|
|
20
18
|
* whole of a token's policy, and declaring them is real work: it is how `expire` stops being
|
|
21
19
|
* whatever somebody typed in 2024. Mint NEW tokens where the secret can be caught — OpenBao's
|
|
22
20
|
* `proxmox-c1` mount, or a human at `pveum user token add`.
|
|
23
|
-
* ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "THE CREATE PATH IS LEFT REACHABLE
|
|
24
|
-
* RATHER THAN STUBBED… the honest arrangement is a create that works". THAT CONTRADICTED THE
|
|
25
|
-
* `reconcile` BELOW, WHICH REFUSES: `handlers.reconcile` calls `Effect.die` by name when `read`
|
|
26
|
-
* answers absent, before any POST — metadata-only per decision 9 (2026-09-23), exported on the
|
|
27
|
-
* barrel as an adopt-and-manage-only Resource. A create that silently did nothing WOULD be the
|
|
28
|
-
* lie resource.ts's ★ describes for a stubbed delete; refusing loudly, by name, with the two
|
|
29
|
-
* ways to get a usable token, is the honest arrangement instead — see the ★ further down and
|
|
30
|
-
* api-token-adopt.test.ts's absent-token row, which pins the refusal and zero writes.
|
|
31
21
|
*
|
|
32
22
|
* ⛔ THERE IS NO `regenerate` PROP, AND IT IS NOT AN OVERSIGHT. PVE's own description: "All users
|
|
33
23
|
* of the previous secret will lose access after this operation." A prop for it would revoke a
|
|
34
24
|
* live credential during a deploy whose plan said `update`, and hand back a replacement this
|
|
35
|
-
* provider is obliged to throw away — breakage with no recovery.
|
|
36
|
-
* that owns the lease, or to a human who is watching.
|
|
25
|
+
* provider is obliged to throw away — breakage with no recovery.
|
|
37
26
|
*
|
|
38
27
|
* ⚠️ THERE IS A SECOND WRITER TO THESE OBJECTS AND IT IS THE ONE THIS PROVIDER RUNS ON. OpenBao's
|
|
39
|
-
* `proxmox-c1` engine mints PVE tokens under `hf-read@pve` and `hf-provision@pve`
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* one object by accident — but they share the USER: `hf-read@pve` held three live leases when
|
|
45
|
-
* this was written, and `Proxmox.User` deleting that account takes every one of them with it.
|
|
46
|
-
* ⛔ DO NOT DECLARE A TOKEN UNDER A MINT USER. `expire` is what makes that dangerous rather than
|
|
47
|
-
* untidy: point this resource at a lease token with the wrong expire and a 300-second
|
|
48
|
-
* credential becomes a permanent one — and on `hf-provision@pve` that credential carries
|
|
49
|
-
* `Permissions.Modify` at `/`. docs/privileges.md records what those 27 privileges buy.
|
|
28
|
+
* `proxmox-c1` engine mints PVE tokens under `hf-read@pve` and `hf-provision@pve` with an id it
|
|
29
|
+
* CHOOSES (`hf-<role>-<actor>-<entity6>-<stamp>-<nonce>`), so the two systems cannot collide on
|
|
30
|
+
* one object by accident — but they share the USER, and `Proxmox.User` deleting that account
|
|
31
|
+
* takes every one of its tokens with it. ⛔ DO NOT DECLARE A TOKEN UNDER A MINT USER: `expire`
|
|
32
|
+
* is what makes that dangerous — a 300-second lease token becomes a permanent one.
|
|
50
33
|
*
|
|
51
34
|
* ⚠️ PRIVILEGES, AND THE READ LANE CANNOT DO IT. MEASURED: all four methods on this path check
|
|
52
35
|
* `['or', ['userid-param','self'], ['userid-group', ['User.Modify']]]` — with no `Sys.Audit`
|
|
53
|
-
* alternative
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
36
|
+
* alternative — so the 3600s read lease reaches at most its OWN account's tokens and is refused
|
|
37
|
+
* for every other userid. `readRole: 'provision'` below, for the same reason storage.ts,
|
|
38
|
+
* sdn-zone.ts and sdn-vnet.ts set it, at the same cost: every plan mints a 300s non-renewable
|
|
39
|
+
* provision lease just to read.
|
|
40
|
+
*
|
|
41
|
+
* ★ MIGRATED OFF `client.ts`'s generic `pve()`/`pveHandlers`/`pveOperations` ONTO
|
|
42
|
+
* `@distilled.cloud/proxmox`'s typed `access.getAccessUserToken`/`putAccessUserToken`/
|
|
43
|
+
* `deleteAccessUserToken` (2026-09-24, decision 43's proxmox walk-down, the last access-family
|
|
44
|
+
* resource). There is no `createAccessUserToken` call anywhere in this file — the create branch
|
|
45
|
+
* was already provably unreachable before the migration (`reconcile` dies on an absent read
|
|
46
|
+
* before any create path runs), so nothing calls distilled's POST-shaped `updateAccessUserToken`
|
|
47
|
+
* operation either; see api-token-form.ts for why the generator gave it that name.
|
|
61
48
|
*/
|
|
62
49
|
import { Resource } from 'alchemy';
|
|
50
|
+
import { isResolved } from 'alchemy/Diff';
|
|
63
51
|
import * as Provider from 'alchemy/Provider';
|
|
52
|
+
import * as access from '@distilled.cloud/proxmox/access';
|
|
64
53
|
import * as Effect from 'effect/Effect';
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
54
|
+
import { API_TOKEN_UPDATE, attributesOf, matches, shape } from './api-token-form.ts';
|
|
55
|
+
import { guardWrite } from './distilled-guard.ts';
|
|
56
|
+
import type { PveRequirements, WithTarget } from './resource-spec.ts';
|
|
57
|
+
import { runPve } from './distilled-pve.ts';
|
|
58
|
+
import {
|
|
59
|
+
UNREADABLE,
|
|
60
|
+
type Unreadable,
|
|
61
|
+
readOrUnreadable,
|
|
62
|
+
unreadableWarning,
|
|
63
|
+
} from './unreadable-read.ts';
|
|
67
64
|
|
|
68
65
|
export interface ApiTokenProps extends WithTarget {
|
|
69
66
|
/**
|
|
@@ -78,57 +75,36 @@ export interface ApiTokenProps extends WithTarget {
|
|
|
78
75
|
/**
|
|
79
76
|
* The token's own name, unique within the account. Identity.
|
|
80
77
|
*
|
|
81
|
-
* ⚠️ PVE HAS NO RENAME
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* is what keeps it from looking like an omission.
|
|
91
|
-
* ⚠️ PATTERN `[A-Za-z][A-Za-z0-9.\-_]+`: a letter first, two characters minimum. The schema
|
|
92
|
-
* declares no maxLength; the OpenBao plugin assumes a conservative 64 rather than finding the
|
|
93
|
-
* real limit in production, and so should anything else.
|
|
78
|
+
* ⚠️ PVE HAS NO RENAME. Editing this prop (or `userid`) points `path` at a DIFFERENT token,
|
|
79
|
+
* which reads as absent and is then created — leaving the old token LIVE, invisible to the
|
|
80
|
+
* plan, while the new one is the unusable kind described at the top. Rename by declaring a
|
|
81
|
+
* delete and a create.
|
|
82
|
+
* ⛔ `diff` IS NOT OVERRIDDEN TO SAY `replace` THE WAY acl.ts DOES: there, replace removes a
|
|
83
|
+
* grant that can be rebuilt from its own tuple; here it would DELETE a live token —
|
|
84
|
+
* destroying an irrecoverable secret — to tidy up a name. Leaving the old token standing is
|
|
85
|
+
* the lesser harm.
|
|
86
|
+
* ⚠️ PATTERN `[A-Za-z][A-Za-z0-9.\-_]+`: a letter first, two characters minimum.
|
|
94
87
|
*/
|
|
95
88
|
tokenid: string;
|
|
96
|
-
/**
|
|
97
|
-
* Free text in `pveum user token list` and the UI. Empty is how it is cleared — see `shape`.
|
|
98
|
-
* ⚠️ ANY CHARACTER IS SAFE HERE, MEASURED, and it was worth checking: `user.cfg` is a
|
|
99
|
-
* colon-delimited line, so a comment holding a colon or a newline is the obvious place for a
|
|
100
|
-
* round-trip to lose a character and diff forever. PVE escapes it — `encode_text` turns
|
|
101
|
-
* `a:b\nc%d` into `a%3Ab%0Ac%25d` and `decode_text` gives it back byte for byte.
|
|
102
|
-
*/
|
|
89
|
+
/** Free text in `pveum user token list` and the UI. Empty is how it is cleared — see `shape`. */
|
|
103
90
|
comment?: string;
|
|
104
91
|
/**
|
|
105
92
|
* Seconds since the epoch, or 0 for "never expires".
|
|
106
93
|
*
|
|
107
94
|
* ⛔ REQUIRED, AND THE SCHEMA WILL TELL YOU IT NEED NOT BE. It declares the default "same as
|
|
108
95
|
* user", and the code never implements it: `generate_token` writes `expire` only when the
|
|
109
|
-
* parameter is defined,
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* back `"expire":0`. So "same as user" is documentation of an intention, not behaviour.
|
|
113
|
-
* ⛔ WHICH IS WHY IT IS NOT OPTIONAL-DEFAULTING-TO-0. That spelling reads identically and is far
|
|
114
|
-
* worse: an omitted `expire` would then quietly WRITE 0 over a live deadline, turning an
|
|
115
|
-
* expiring credential into a permanent one, and the plan would call it an update. Requiring
|
|
116
|
-
* the field makes "this token never expires" a sentence somebody had to type.
|
|
96
|
+
* parameter is defined, and the parser does `$expire = 0 if !$expire`. Required, so "this
|
|
97
|
+
* token never expires" is a sentence somebody had to type, rather than an omission that would
|
|
98
|
+
* quietly WRITE 0 over a live deadline.
|
|
117
99
|
*/
|
|
118
100
|
expire: number;
|
|
119
101
|
/**
|
|
120
102
|
* True keeps the token's privileges separate from its owner's — it gets NOTHING until an ACL
|
|
121
103
|
* names `fullTokenid`. False gives it the owner's privileges entire.
|
|
122
104
|
*
|
|
123
|
-
* ⛔ REQUIRED FOR THE SAME REASON AS `expire`, AND THE BLAST RADIUS IS LARGER.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* preserve. Were this optional, adopting a live token without mentioning `privsep` would plan
|
|
127
|
-
* an update to 1 and STRIP a working credential of every privilege it has, silently: PVE
|
|
128
|
-
* returns 401/403 to the service, nothing errors here, and `iac@pve!apply` simply stops
|
|
129
|
-
* working. Required, so adopting a token is a sentence that states what it is.
|
|
130
|
-
* ⚠️ AND `false` IS NOT A SHRUG. It is a token with its owner's whole privilege set; under a
|
|
131
|
-
* provisioning account that is the account's full authority with a separate secret.
|
|
105
|
+
* ⛔ REQUIRED FOR THE SAME REASON AS `expire`, AND THE BLAST RADIUS IS LARGER. Were this
|
|
106
|
+
* optional, adopting a live token without mentioning `privsep` would plan an update to 1 and
|
|
107
|
+
* STRIP a working credential of every privilege it has, silently.
|
|
132
108
|
*/
|
|
133
109
|
privsep: boolean;
|
|
134
110
|
}
|
|
@@ -143,9 +119,7 @@ export interface ApiTokenAttributes {
|
|
|
143
119
|
privsep: boolean;
|
|
144
120
|
/**
|
|
145
121
|
* ⛔ THERE IS NO `value` FIELD HERE AND THERE NEVER MAY BE. Adding one would write a working PVE
|
|
146
|
-
* credential, in clear, into the state Postgres and into every nightly dump of it
|
|
147
|
-
* leak metric-server.ts types its `token` as `never` to prevent. The read cannot supply one
|
|
148
|
-
* anyway; only create and regenerate can, and both of those responses are dropped.
|
|
122
|
+
* credential, in clear, into the state Postgres and into every nightly dump of it.
|
|
149
123
|
*/
|
|
150
124
|
}
|
|
151
125
|
|
|
@@ -159,69 +133,109 @@ export interface ProxmoxApiToken extends Resource<
|
|
|
159
133
|
|
|
160
134
|
/**
|
|
161
135
|
* ★ `retain` BY DEFAULT, AND THIS IS THE PLAINEST CASE FOR IT IN THE PACKAGE. A token's contents
|
|
162
|
-
* are one irreplaceable secret: delete it and the value is gone,
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
* exporter feeding VictoriaMetrics), `agent@pve!executor`, `iac@pve!apply`, `iac@pve!ro`,
|
|
166
|
-
* `app@pve!app` and `mint@pve!engine` — the last being the parent credential the
|
|
167
|
-
* OpenBao mount itself authenticates with, so orphaning that one would stop every plan in this
|
|
168
|
-
* package, this resource included. `delete` is FULLY IMPLEMENTED (DELETE is a real method on
|
|
169
|
-
* this path, measured) and runs the moment a caller opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
170
|
-
* See the ★ in resource.ts, which explains the convention once.
|
|
136
|
+
* are one irreplaceable secret: delete it and the value is gone, and every holder loses access
|
|
137
|
+
* the instant `cfs_write_file` returns — with no error raised anywhere near them. `delete` is
|
|
138
|
+
* FULLY IMPLEMENTED and runs the moment a caller opts in with `.pipe(RemovalPolicy.destroy())`.
|
|
171
139
|
*/
|
|
172
140
|
export const ProxmoxApiToken = Resource<ProxmoxApiToken>('Proxmox.ApiToken', {
|
|
173
141
|
defaultRemovalPolicy: 'retain',
|
|
174
142
|
});
|
|
175
143
|
|
|
176
|
-
|
|
144
|
+
/** ⛔ `readRole: 'provision'` — see the header's ⚠️ on privileges. */
|
|
145
|
+
const readToken = (props: ApiTokenProps) =>
|
|
146
|
+
readOrUnreadable(runPve(props.target, 'provision', false, access.getAccessUserToken(props))).pipe(
|
|
147
|
+
Effect.map((live) => (live === UNREADABLE ? UNREADABLE : attributesOf(live, props))),
|
|
148
|
+
Effect.orElseSucceed(() => undefined),
|
|
149
|
+
);
|
|
177
150
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
* returns `["full-tokenid","info","value"]`, where `value` is "API token value used for
|
|
182
|
-
* authentication", while `GET` returns `["comment","expire","privsep"]` and never the secret.
|
|
183
|
-
* It may not become an attribute, because Alchemy writes attributes to its state store
|
|
184
|
-
* UNENCRYPTED into a Postgres that is dumped nightly.
|
|
185
|
-
*
|
|
186
|
-
* So a token created here would be A LIVE CREDENTIAL ON THE CLUSTER THAT NOBODY HOLDS: valid,
|
|
187
|
-
* privileged, unusable, and indistinguishable from one somebody meant to keep. Regenerating does
|
|
188
|
-
* not rescue it — a `regenerate` PUT returns the new value down the same discarded path. So
|
|
189
|
-
* `reconcile` refuses by name and says what to do instead.
|
|
190
|
-
*
|
|
191
|
-
* ★ EVERYTHING ELSE STILL WORKS, AND IT IS THE HALF WORTH HAVING. Adopting an existing token and
|
|
192
|
-
* converging its `comment`, `expire` and `privsep` are real operations: C1 carries SEVEN tokens
|
|
193
|
-
* with `expire=0` and `privsep=0` made by clicks nobody recorded. Declaring those freezes the
|
|
194
|
-
* set, and an eighth appearing shows up as drift.
|
|
195
|
-
*
|
|
196
|
-
* ⚠️ IF DECLARATIVE MINTING IS EVER WANTED, the missing piece is a secret SINK — write the value
|
|
197
|
-
* straight into an OpenBao kv path and return only its address. That is a different resource
|
|
198
|
-
* with a different contract; it must not be bolted onto this one.
|
|
199
|
-
*/
|
|
200
|
-
const handlers = {
|
|
201
|
-
...pveHandlers(apiTokenSpec),
|
|
202
|
-
reconcile: Effect.fn(function* ({ news }: { news: ApiTokenProps }) {
|
|
203
|
-
const live = yield* ops.read(news);
|
|
204
|
-
if (live === undefined) {
|
|
205
|
-
return yield* Effect.die(
|
|
206
|
-
new Error(
|
|
207
|
-
`${news.userid}!${news.tokenid}: this resource does not create API tokens. PVE returns ` +
|
|
208
|
-
'the secret only in the create response and it cannot be stored, so a token made here ' +
|
|
209
|
-
'would be a live credential nobody holds. Create it with `pveum user token add` and ' +
|
|
210
|
-
'capture the value, or mint a short-lived one from the OpenBao proxmox mount ' +
|
|
211
|
-
'(`bao read proxmox-c1/creds/<role>`) -- then declare it here to manage it.',
|
|
212
|
-
),
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
return yield* ops.reconcile(news);
|
|
216
|
-
}),
|
|
217
|
-
};
|
|
151
|
+
/** `read`/`reconcile` return `Attributes | undefined`; only `diff` tells `UNREADABLE` apart. */
|
|
152
|
+
const dropUnreadable = (live: ApiTokenAttributes | Unreadable | undefined) =>
|
|
153
|
+
live === UNREADABLE ? undefined : live;
|
|
218
154
|
|
|
219
155
|
/**
|
|
220
|
-
* ⛔
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
* would report drift against a credential that was never its to hold.
|
|
156
|
+
* ⛔ THIS FAMILY ADOPTS AND MANAGES TOKENS; IT REFUSES TO MINT ONE — see the header. `reconcile`
|
|
157
|
+
* refuses by name before any write when the read finds nothing, so the create path this
|
|
158
|
+
* family's spec would otherwise need never runs, and no `createAccessUserToken`-shaped call
|
|
159
|
+
* exists in this file at all.
|
|
225
160
|
*/
|
|
226
161
|
export const ProxmoxApiTokenProvider = () =>
|
|
227
|
-
Provider.effect(
|
|
162
|
+
Provider.effect(
|
|
163
|
+
ProxmoxApiToken,
|
|
164
|
+
Effect.succeed(
|
|
165
|
+
ProxmoxApiToken.Provider.of({
|
|
166
|
+
/**
|
|
167
|
+
* ⛔ `list` IS EMPTY, AND FOR THIS FAMILY THAT MATTERS MORE THAN THE GENERIC ARGUMENT.
|
|
168
|
+
* A token index handed to Alchemy would offer up `mint@pve!engine` and every live
|
|
169
|
+
* OpenBao lease for adoption — and adoption is what makes a later plan willing to delete.
|
|
170
|
+
*/
|
|
171
|
+
list: () => Effect.succeed([]),
|
|
172
|
+
read: Effect.fn(function* ({ olds }) {
|
|
173
|
+
return dropUnreadable(yield* readToken(olds));
|
|
174
|
+
}),
|
|
175
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
176
|
+
if (!isResolved(news)) return undefined;
|
|
177
|
+
yield* guardWrite(API_TOKEN_UPDATE, shape(news), false);
|
|
178
|
+
if (output === undefined) return undefined;
|
|
179
|
+
const live = yield* readToken(news);
|
|
180
|
+
// ⛔ THE CRIES-WOLF FIX: a refused read used to fall into `undefined` below and force
|
|
181
|
+
// `update` on a token that was plainly there — see unreadable-read.ts.
|
|
182
|
+
if (live === UNREADABLE) {
|
|
183
|
+
yield* unreadableWarning('Proxmox.ApiToken', `${news.userid}!${news.tokenid}`);
|
|
184
|
+
return { action: 'noop' } as const;
|
|
185
|
+
}
|
|
186
|
+
// ⚠️ NO GUARDED-CREATE BRANCH HERE, UNLIKE EVERY OTHER FAMILY IN THIS SUB-AREA. There
|
|
187
|
+
// is nothing to guard a create form for — `reconcile` refuses instead, and `diff`
|
|
188
|
+
// answering `update` for an absent token is what routes a real deploy into that
|
|
189
|
+
// refusal rather than silently reporting `noop` over a token that never landed.
|
|
190
|
+
return live !== undefined && matches(live, news)
|
|
191
|
+
? ({ action: 'noop' } as const)
|
|
192
|
+
: ({ action: 'update' } as const);
|
|
193
|
+
}),
|
|
194
|
+
reconcile: Effect.fn(function* ({ news }) {
|
|
195
|
+
// ⚠️ `dropUnreadable`: reconcile only runs once `provision` already minted for the
|
|
196
|
+
// read that fed `diff`'s `update` verdict, so `UNREADABLE` here is a narrow race —
|
|
197
|
+
// but UNLIKE group/role/user, the message below assumes genuine absence and would
|
|
198
|
+
// misname a transient credential refusal as "this resource does not create tokens".
|
|
199
|
+
// Rare (the SAME role must mint twice, once for diff/once for reconcile, and only the
|
|
200
|
+
// second is denied) and not silently wrong — the operator still sees a die() and goes
|
|
201
|
+
// looking — but named here rather than left for the next reader to assume away.
|
|
202
|
+
const before = dropUnreadable(yield* readToken(news));
|
|
203
|
+
if (before === undefined) {
|
|
204
|
+
return yield* Effect.die(
|
|
205
|
+
new Error(
|
|
206
|
+
`${news.userid}!${news.tokenid}: this resource does not create API tokens. PVE ` +
|
|
207
|
+
'returns the secret only in the create response and it cannot be stored, so a ' +
|
|
208
|
+
'token made here would be a live credential nobody holds. Create it with ' +
|
|
209
|
+
'`pveum user token add` and capture the value, or mint a short-lived one from ' +
|
|
210
|
+
'the OpenBao proxmox mount (`bao read proxmox-c1/creds/<role>`) -- then declare ' +
|
|
211
|
+
'it here to manage it.',
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
yield* guardWrite(API_TOKEN_UPDATE, shape(news), false);
|
|
216
|
+
if (!matches(before, news)) {
|
|
217
|
+
yield* runPve(news.target, 'provision', true, access.putAccessUserToken(shape(news)));
|
|
218
|
+
}
|
|
219
|
+
const after = dropUnreadable(yield* readToken(news));
|
|
220
|
+
if (after === undefined) {
|
|
221
|
+
return yield* Effect.die(
|
|
222
|
+
new Error(
|
|
223
|
+
`${news.userid}!${news.tokenid}: the write returned no error but the token is ` +
|
|
224
|
+
'still absent. PVE wraps every answer in {"data":...} and can report success ' +
|
|
225
|
+
'on a call that did nothing -- read back rather than trusting the status code.',
|
|
226
|
+
),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return after;
|
|
230
|
+
}),
|
|
231
|
+
delete: Effect.fn(function* ({ olds }) {
|
|
232
|
+
yield* runPve(
|
|
233
|
+
olds.target,
|
|
234
|
+
'provision',
|
|
235
|
+
true,
|
|
236
|
+
access.deleteAccessUserToken({ tokenid: olds.tokenid, userid: olds.userid }),
|
|
237
|
+
);
|
|
238
|
+
}),
|
|
239
|
+
}),
|
|
240
|
+
),
|
|
241
|
+
);
|
package/src/proxmox/client.ts
CHANGED
|
@@ -15,14 +15,9 @@
|
|
|
15
15
|
import * as Effect from 'effect/Effect';
|
|
16
16
|
import * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
17
17
|
import * as HttpClientRequest from 'effect/unstable/http/HttpClientRequest';
|
|
18
|
-
import {
|
|
19
|
-
type ApiTarget,
|
|
20
|
-
type PveCredential,
|
|
21
|
-
type PveRole,
|
|
22
|
-
type PveTarget,
|
|
23
|
-
authorization,
|
|
24
|
-
} from './credentials.ts';
|
|
18
|
+
import type { ApiTarget, PveCredential, PveRole, PveTarget } from './credentials.ts';
|
|
25
19
|
import { leased } from './lease-cache.ts';
|
|
20
|
+
import { authorization } from './mint.ts';
|
|
26
21
|
import { executeOnCluster } from './members.ts';
|
|
27
22
|
|
|
28
23
|
export class PveError extends Error {
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
* there).
|
|
17
17
|
*/
|
|
18
18
|
import { describe, expect, test } from 'bun:test';
|
|
19
|
-
import { shape as apiTokenForm } from './api-token-form.ts';
|
|
20
19
|
import { NETWORK_APPLY_ENDPOINT, SDN_APPLY_ENDPOINT } from './apply-endpoints.ts';
|
|
21
20
|
import { constraintsFor, formViolations } from './constraint-guard.ts';
|
|
22
21
|
import type { PveTarget } from './credentials.ts';
|
|
@@ -30,32 +29,13 @@ const TARGET: PveTarget = { members: ['pve.test:8006'], mount: 'pve-test', schem
|
|
|
30
29
|
const onCreate = (key: string, form: Record<string, string | readonly string[]>) =>
|
|
31
30
|
formViolations(key, form, true);
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
comment: 'Declared by Alchemy',
|
|
41
|
-
expire: 0,
|
|
42
|
-
privsep: false,
|
|
43
|
-
target: TARGET,
|
|
44
|
-
tokenid: 'apply',
|
|
45
|
-
userid: 'iac@pve',
|
|
46
|
-
}),
|
|
47
|
-
),
|
|
48
|
-
).toEqual([]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
/** ⛔ `{userid}` AND `{tokenid}` ARE PATH SEGMENTS. A table that kept them would refuse every
|
|
52
|
-
* create for a "missing" parameter that was never missing (codegen/emit.ts). */
|
|
53
|
-
test('neither path parameter is in the table', () => {
|
|
54
|
-
const table = constraintsFor('pve:POST /access/users/{userid}/token/{tokenid}');
|
|
55
|
-
expect(table['userid']).toBeUndefined();
|
|
56
|
-
expect(table['tokenid']).toBeUndefined();
|
|
57
|
-
});
|
|
58
|
-
});
|
|
32
|
+
// ⛔ Proxmox.ApiToken's own create-endpoint check was REMOVED HERE 2026-09-24, not merely
|
|
33
|
+
// updated: the distilled migration (api-token.ts) confirmed the create path was already
|
|
34
|
+
// unreachable (reconcile refuses before any POST — see that file's header) and stopped
|
|
35
|
+
// guarding a create form at all, so `'pve:POST /access/users/{userid}/token/{tokenid}'` is no
|
|
36
|
+
// longer named anywhere in this package's source. `codegen/constraints.ts` (its own `keysFromSource`)
|
|
37
|
+
// dropped the table for it on the next regeneration — there is nothing left here to test
|
|
38
|
+
// against, and keeping this block would fail with "no vendor constraint table" forever.
|
|
59
39
|
|
|
60
40
|
describe('Proxmox.MetricServer plans clean against POST /cluster/metrics/server/{id}', () => {
|
|
61
41
|
/**
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `credentials.ts`'s one typed mint failure (S21) — split out to keep that file under the
|
|
3
|
+
* 250-line cap once this class grew a real header (2026-09-24, the cries-wolf fix).
|
|
4
|
+
*/
|
|
5
|
+
import * as Data from 'effect/Data';
|
|
6
|
+
import type { PveRole } from './credentials.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OpenBao answered 403 to `GET /v1/<mount>/creds/<role>`: this identity's AppRole has no grant
|
|
10
|
+
* for `role` on `mount` — measured 2026-09-24 as "the agent lane cannot mint `provision`" (the
|
|
11
|
+
* lane's policy grants `read` but not `provision`). A TYPED tag, not the plain `Error` every
|
|
12
|
+
* other mint failure still is (404 no such role/mount, 503 sealed, a network failure), because
|
|
13
|
+
* this ONE case means "refused, not evidence of anything about the object" — a family's
|
|
14
|
+
* `readOrUnreadable` (unreadable-read.ts) `catchTag`s exactly this, never any other mint
|
|
15
|
+
* failure, which stays a hard error precisely because it is NOT a scoped refusal.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ WHY NOT EVERY NON-2xx: A 503 (sealed) or a 404 (misconfigured mount/role) is the estate
|
|
18
|
+
* BROKEN, not this identity being narrowly scoped — folding those into "unreadable, report
|
|
19
|
+
* noop" would hide a real outage behind a quiet, misleadingly reassuring plan. Only 403 is
|
|
20
|
+
* "this identity, this role, denied" and safe to read as "cannot see, not evidence of absence".
|
|
21
|
+
*/
|
|
22
|
+
export class PveCredentialDenied extends Data.TaggedError('PveCredentialDenied')<{
|
|
23
|
+
readonly mount: string;
|
|
24
|
+
readonly role: PveRole;
|
|
25
|
+
readonly detail: string;
|
|
26
|
+
}> {
|
|
27
|
+
override get message(): string {
|
|
28
|
+
return (
|
|
29
|
+
`OpenBao GET /v1/${this.mount}/creds/${this.role} -> 403: ${this.detail}. This identity's ` +
|
|
30
|
+
'AppRole has no grant for this role -- a refused read, not evidence the object is gone.'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -12,7 +12,8 @@ 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 {
|
|
15
|
+
import type { BaoEnvironment, PveTarget } from './credentials.ts';
|
|
16
|
+
import { mint } from './mint.ts';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* ⚠️ THIS PACKAGE'S tsconfig CARRIES NO BUN TYPES, and its package.json is out of bounds for this
|
|
@@ -100,8 +101,8 @@ describe('mint', () => {
|
|
|
100
101
|
});
|
|
101
102
|
});
|
|
102
103
|
|
|
103
|
-
it('fails
|
|
104
|
-
for (const status of [
|
|
104
|
+
it('fails 404 and 503 with OpenBao errors, never the token', async () => {
|
|
105
|
+
for (const status of [404, 503]) {
|
|
105
106
|
await withFake(
|
|
106
107
|
{ json: { errors: [`status ${String(status)}`] }, status },
|
|
107
108
|
async (address) => {
|
|
@@ -117,6 +118,31 @@ describe('mint', () => {
|
|
|
117
118
|
}
|
|
118
119
|
});
|
|
119
120
|
|
|
121
|
+
// ★ 403 IS THE ONE STATUS WITH ITS OWN TAG (credential-errors.ts) — a family's
|
|
122
|
+
// `readOrUnreadable` (unreadable-read.ts) `catchTag`s exactly this, never 404/503 above.
|
|
123
|
+
it('fails 403 as the typed PveCredentialDenied, never the token', async () => {
|
|
124
|
+
await withFake({ json: { errors: ['status 403'] }, status: 403 }, async (address) => {
|
|
125
|
+
const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
|
|
126
|
+
assert.ok(error instanceof Error);
|
|
127
|
+
assert.equal((error as { _tag?: string })._tag, 'PveCredentialDenied');
|
|
128
|
+
assert.match(error.message, /-> 403: status 403\. This identity's AppRole has no grant/);
|
|
129
|
+
assert.ok(!error.message.includes(TOKEN));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ⛔ FOUND ON ADVERSARIAL REVIEW 2026-09-24: status alone is not enough. A WAF or a proxy in
|
|
134
|
+
// front of OpenBao can answer 403 without OpenBao ever seeing the request — folding THAT into
|
|
135
|
+
// `PveCredentialDenied` would hide a real outage behind the same "noop, nothing to worry
|
|
136
|
+
// about" reading the cries-wolf fix exists to stop, only for a different cause.
|
|
137
|
+
it('a 403 with no OpenBao error body stays the untyped refusal, not PveCredentialDenied', async () => {
|
|
138
|
+
await withFake({ json: { message: 'Forbidden' }, status: 403 }, async (address) => {
|
|
139
|
+
const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
|
|
140
|
+
assert.ok(error instanceof Error);
|
|
141
|
+
assert.equal((error as { _tag?: string })._tag, undefined);
|
|
142
|
+
assert.match(error.message, /-> 403: not an OpenBao error body/);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
120
146
|
it('refuses a 200 with no token_id, without quoting the secret', async () => {
|
|
121
147
|
await withFake({ json: { data: { secret: SECRET } }, status: 200 }, async (address) => {
|
|
122
148
|
const error = await refused({ BAO_ADDR: address, BAO_TOKEN: TOKEN });
|