@elinpf/dsh-ops-access 0.1.7 → 0.2.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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Hub backend: fetch credentials from a remote ops-access-hub service.
3
+ *
4
+ * The hub stores file-field CONTENT; this backend converts at the boundary:
5
+ *
6
+ * - `loadTier` downloads the tier's fields and MATERIALIZES each declared
7
+ * file field to a managed local file (`<credentialsDir>/<kind>/<name>/<tier>/<field>`,
8
+ * 0600, atomic write, skipped when the content is unchanged), substituting
9
+ * the local path — so downstream consumers (kubectl/ssh CLIs, provider
10
+ * schemas, probes) see exactly the same provider-shaped profile the YAML
11
+ * backend serves, and secret paths never leave the machine.
12
+ * - `putTier` reads the managed local files back and uploads their CONTENT
13
+ * (the write path — register_access, the admin UI — stages content files
14
+ * locally first, exactly as in YAML mode).
15
+ *
16
+ * Every call hits the hub — nothing is cached, mirroring the YAML backend's
17
+ * re-read-on-every-call discipline. Listing and metadata reads never carry
18
+ * field values; secret content crosses the wire only on the resolve and
19
+ * write paths, over the operator-managed channel (the hub binds loopback or
20
+ * sits behind a TLS-terminating reverse proxy).
21
+ *
22
+ * @module @elinpf/dsh-ops-access/hub-backend
23
+ */
24
+ import { mkdir, readdir, readFile, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
25
+ import { dirname } from 'node:path';
26
+ import os from 'node:os';
27
+ import { buildEnvelope, isPlainObject, mergeEnvelope, probeOf } from './backend.js';
28
+ /** Expand a leading `~` (or `~/`) to the user's home directory. */
29
+ function expandHome(p) {
30
+ const home = process.env.HOME ?? os.homedir();
31
+ if (p === '~')
32
+ return home;
33
+ if (p.startsWith('~/'))
34
+ return home + p.slice(1);
35
+ return p;
36
+ }
37
+ /**
38
+ * Write content to a managed file, skipping the write when the on-disk
39
+ * bytes already match (resolve runs on every tool call — touching the file
40
+ * every time would churn mtimes and race concurrent writers). Atomic via
41
+ * write-temp-then-rename; mode 0600 — the file carries secret material.
42
+ */
43
+ async function writeIfChanged(filePath, content) {
44
+ try {
45
+ if ((await readFile(filePath, 'utf8')) === content)
46
+ return;
47
+ }
48
+ catch (err) {
49
+ if (err?.code !== 'ENOENT')
50
+ throw err;
51
+ }
52
+ await mkdir(dirname(filePath), { recursive: true });
53
+ const tmp = `${filePath}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
54
+ await writeFile(tmp, content, { encoding: 'utf8', mode: 0o600 });
55
+ await rename(tmp, filePath);
56
+ }
57
+ /**
58
+ * Delete materialized credential files older than maxAgeMs under
59
+ * credentialsDir (pass 0 to sweep everything), then remove the directories
60
+ * left empty. In hub mode every local credential file is a TTL-bound cache
61
+ * of hub content — never a permanent copy: resolve re-materializes on demand
62
+ * (writeIfChanged), so deletion is always safe and transparent to consumers.
63
+ * Best-effort: individual failures are skipped, the next sweep retries.
64
+ * Returns the number of files removed.
65
+ */
66
+ export async function sweepMaterialized(credentialsDir, maxAgeMs) {
67
+ const now = Date.now();
68
+ let removed = 0;
69
+ const walk = async (dir, isRoot) => {
70
+ let entries;
71
+ try {
72
+ entries = await readdir(dir, { withFileTypes: true });
73
+ }
74
+ catch {
75
+ return; // missing/unreadable dir — nothing to sweep
76
+ }
77
+ for (const entry of entries) {
78
+ const p = `${dir}/${entry.name}`;
79
+ if (entry.isDirectory()) {
80
+ await walk(p, false);
81
+ }
82
+ else {
83
+ const st = await stat(p).catch(() => null);
84
+ if (!st || !st.isFile())
85
+ continue;
86
+ if (maxAgeMs > 0 && now - st.mtimeMs <= maxAgeMs)
87
+ continue;
88
+ await rm(p, { force: true }).catch(() => { });
89
+ removed++;
90
+ }
91
+ }
92
+ // Prune emptied dirs on the way up (rmdir refuses non-empty — a
93
+ // concurrent materialization racing the sweep is never harmed).
94
+ if (!isRoot)
95
+ await rmdir(dir).catch(() => { });
96
+ };
97
+ await walk(credentialsDir, true);
98
+ return removed;
99
+ }
100
+ /** Sanitize one entry of the hub's GET /entries response (durable boundary). */ function sanitizeEntry(raw) {
101
+ if (!isPlainObject(raw))
102
+ return null;
103
+ if (typeof raw.kind !== 'string' || typeof raw.name !== 'string')
104
+ return null;
105
+ const tiers = {};
106
+ if (isPlainObject(raw.tiers)) {
107
+ for (const tier of ['ro', 'rw']) {
108
+ const t = raw.tiers[tier];
109
+ if (t === undefined || t === null)
110
+ continue;
111
+ const probe = probeOf(t);
112
+ tiers[tier] = probe !== undefined ? { probe } : {};
113
+ }
114
+ }
115
+ return { kind: raw.kind, name: raw.name, envelope: buildEnvelope([isPlainObject(raw.envelope) ? raw.envelope : undefined]), tiers };
116
+ }
117
+ export class HubBackend {
118
+ opts;
119
+ label;
120
+ constructor(opts) {
121
+ this.opts = opts;
122
+ this.label = `access hub at ${opts.baseUrl}`;
123
+ }
124
+ /**
125
+ * One HTTP call. Returns null on 404; throws on every other failure with
126
+ * the hub's error message (which never carries field values). The auth
127
+ * token rides an Authorization header and never lands in error text.
128
+ */
129
+ async request(method, path, body, admin = false) {
130
+ const token = admin ? this.opts.adminToken : this.opts.readToken;
131
+ let res;
132
+ try {
133
+ res = await fetch(this.opts.baseUrl + path, {
134
+ method,
135
+ headers: {
136
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
137
+ ...(token !== '' ? { authorization: `Bearer ${token}` } : {}),
138
+ },
139
+ body: body !== undefined ? JSON.stringify(body) : undefined,
140
+ });
141
+ }
142
+ catch (err) {
143
+ throw new Error(`ops-access: cannot reach ${this.label}: ${err?.message ?? err}`);
144
+ }
145
+ if (res.status === 404)
146
+ return null;
147
+ const text = await res.text();
148
+ let parsed = null;
149
+ if (text !== '') {
150
+ try {
151
+ parsed = JSON.parse(text);
152
+ }
153
+ catch {
154
+ throw new Error(`ops-access: ${this.label} returned a non-JSON response (${res.status})`);
155
+ }
156
+ }
157
+ if (!res.ok) {
158
+ const message = isPlainObject(parsed) && typeof parsed.error === 'string' ? parsed.error : res.statusText;
159
+ throw new Error(`ops-access: ${this.label} rejected ${method} ${path} (${res.status}): ${message}`);
160
+ }
161
+ return parsed;
162
+ }
163
+ async listEntries() {
164
+ const data = await this.request('GET', '/entries');
165
+ if (!Array.isArray(data))
166
+ return [];
167
+ return data
168
+ .map(sanitizeEntry)
169
+ .filter((e) => e !== null)
170
+ .sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name));
171
+ }
172
+ async loadTier(kind, name, tier, loadOpts) {
173
+ const data = await this.request('GET', `/entries/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${tier}`);
174
+ if (data === null || !isPlainObject(data) || !isPlainObject(data.fields))
175
+ return null;
176
+ const fields = { ...data.fields };
177
+ const provider = this.opts.getProvider(kind);
178
+ const materialize = loadOpts?.materialize !== false;
179
+ // File fields arrive as CONTENT; substitute the managed local path,
180
+ // writing the file only when the credential is actually being issued
181
+ // (materialize) — metadata reads must not persist secret material.
182
+ for (const ff of provider?.fileFields ?? []) {
183
+ const content = fields[ff];
184
+ if (typeof content !== 'string' || content === '')
185
+ continue;
186
+ const target = `${this.opts.cacheDir}/${kind}/${name}/${tier}/${ff}`;
187
+ if (materialize)
188
+ await writeIfChanged(target, content);
189
+ fields[ff] = target;
190
+ }
191
+ const probe = probeOf(data);
192
+ return {
193
+ fields,
194
+ envelope: buildEnvelope([isPlainObject(data.envelope) ? data.envelope : undefined]),
195
+ ...(probe !== undefined ? { probe } : {}),
196
+ };
197
+ }
198
+ async putTier(kind, name, tier, fields, envelope, probe) {
199
+ const provider = this.opts.getProvider(kind);
200
+ const out = { ...fields };
201
+ // Paths → content: the hub stores the secret material itself. An
202
+ // unreadable managed file fails loud — a half-written credential on the
203
+ // hub is worse than no write.
204
+ for (const ff of provider?.fileFields ?? []) {
205
+ const value = out[ff];
206
+ if (typeof value !== 'string' || value === '')
207
+ continue;
208
+ const source = expandHome(value);
209
+ try {
210
+ out[ff] = await readFile(source, 'utf8');
211
+ }
212
+ catch (err) {
213
+ throw new Error(`ops-access: cannot read credential file ${source} for upload to the hub: ${err?.message ?? err}`);
214
+ }
215
+ }
216
+ // Envelope merge needs the entry's current envelope (the hub replaces it
217
+ // wholesale); the fields-free listing carries it.
218
+ const entries = await this.listEntries().catch(() => []);
219
+ const existing = entries.find((e) => e.kind === kind && e.name === name);
220
+ const merged = mergeEnvelope(existing?.envelope ?? {}, envelope);
221
+ await this.request('PUT', `/entries/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${tier}`, {
222
+ fields: out,
223
+ envelope: merged,
224
+ ...(probe !== undefined ? { probe } : {}),
225
+ }, true);
226
+ }
227
+ async deleteTier(kind, name, tier) {
228
+ const res = await this.request('DELETE', `/entries/${encodeURIComponent(kind)}/${encodeURIComponent(name)}/${tier}`, undefined, true);
229
+ if (res === null)
230
+ return 'missing';
231
+ // Whether the whole entry went with the tier decides the managed-dir
232
+ // cleanup scope — re-list and look.
233
+ const entries = await this.listEntries().catch(() => []);
234
+ return entries.some((e) => e.kind === kind && e.name === name) ? 'tier' : 'entry';
235
+ }
236
+ // ── Registration-request queue (hub-only, not part of AccessBackend) ──────
237
+ // The agent-facing rw write path: submit a request, a human approves it in
238
+ // the admin UI, and only then does the hub write the tier. Fields carry
239
+ // CONTENT here (the agent pastes secret material directly — there is no
240
+ // local staging file on this path).
241
+ async submitRequest(req) {
242
+ const data = await this.request('POST', '/requests', {
243
+ kind: req.kind,
244
+ name: req.name,
245
+ tier: req.tier,
246
+ fields: req.fields,
247
+ ...(req.envelope !== undefined ? { envelope: req.envelope } : {}),
248
+ ...(req.reason !== undefined ? { reason: req.reason } : {}),
249
+ }, true);
250
+ if (!isPlainObject(data) || typeof data.id !== 'string') {
251
+ throw new Error(`ops-access: ${this.label} returned a malformed request id`);
252
+ }
253
+ return data.id;
254
+ }
255
+ /** Pending-request metadata for the approval UI — field values never cross. */
256
+ async listRequests(status) {
257
+ return this.request('GET', status === undefined ? '/requests' : `/requests?status=${status}`);
258
+ }
259
+ /** Full request incl. field values, for pre-approval review. Null when absent. */
260
+ async getRequest(id) {
261
+ return this.request('GET', `/requests/${encodeURIComponent(id)}`, undefined, true);
262
+ }
263
+ /** Approve (hub writes the tier) or reject. Returns false when already settled/absent. */
264
+ async decideRequest(id, approved) {
265
+ const data = await this.request('POST', `/requests/${encodeURIComponent(id)}/decide`, { approved }, true);
266
+ return data !== null;
267
+ }
268
+ }
package/lib/index.d.ts CHANGED
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * Ops access capability seam.
3
3
  *
4
- * Owns the YAML credential registry file (default `~/.dsh-ops/access.yaml`)
5
- * and exposes `ctx.opsAccess`: a generic `resolve(kind, name)` / `list()`
6
- * entry plus a `register(provider)` surface for provider plugins. Providers
7
- * (one per credential kind) supply the zod schema for their entry shape and
8
- * an optional `process` step (e.g. `~` expansion); secret material never
9
- * leaves the filesystem — profiles carry only paths and connection params.
4
+ * Exposes `ctx.opsAccess`: a generic `resolve(kind, name)` / `list()` entry
5
+ * plus a `register(provider)` surface for provider plugins. Providers (one
6
+ * per credential kind) supply the zod schema for their entry shape and an
7
+ * optional `process` step (e.g. `~` expansion); profiles carry only paths
8
+ * and connection params, never inline secret material.
10
9
  *
11
- * The registry file is re-read, re-parsed, and re-validated on every call —
12
- * edits take effect immediately, nothing is cached.
10
+ * The credential SOURCE is pluggable (see backend.ts):
11
+ * - 'yaml' (default) owns the local YAML registry file
12
+ * (`~/.dsh-ops/access.yaml`), re-read, re-parsed, and re-validated on
13
+ * every call — edits take effect immediately, nothing is cached.
14
+ * - 'hub' fetches entries from a remote ops-access-hub service on every
15
+ * call (hub-backend.ts): file-field CONTENT is materialized to managed
16
+ * local files at resolve time, so profiles still carry only paths.
13
17
  *
14
18
  * Also registers the `register_access` tool: the agent's self-service path
15
- * for writing the ro tier of a profile (rw tiers stay human-managed via the
16
- * admin HTTP routes below).
19
+ * for writing the ro tier of a profile. rw tiers stay human-approved the
20
+ * tool can only QUEUE an rw registration request on the hub (hub mode);
21
+ * a human approves it in the admin UI before anything is written.
17
22
  *
18
- * Registry format:
23
+ * Registry format (yaml source):
19
24
  *
20
25
  * ```yaml
21
26
  * version: 1
@@ -42,10 +47,37 @@ import type { AccessProvider, AccessBroker, OpsAccess } from './types.js';
42
47
  export declare const name = "ops-access";
43
48
  export declare const inject: string[];
44
49
  export interface Config {
45
- /** Path to the YAML access registry; a leading `~` expands to $HOME. */
50
+ /** Path to the YAML access registry; a leading `~` expands to $HOME. (source: 'yaml') */
46
51
  registryFile: string;
47
52
  /** Root directory for managed credential content files; a leading `~` expands to $HOME. */
48
53
  credentialsDir: string;
54
+ /**
55
+ * Credential source: 'yaml' (default) reads the local registry file;
56
+ * 'hub' fetches entries from a remote ops-access-hub service on every
57
+ * call and materializes file-field content to managed local files.
58
+ */
59
+ source?: 'yaml' | 'hub';
60
+ /** Hub base URL (source: 'hub'), e.g. http://127.0.0.1:3090. */
61
+ hubUrl?: string;
62
+ /** Hub read token (source: 'hub'); falls back to env ACCESS_HUB_READ_TOKEN. Never logged. */
63
+ hubToken?: string;
64
+ /** Hub admin token for write/delete (source: 'hub'); falls back to env ACCESS_HUB_ADMIN_TOKEN. Never logged. */
65
+ hubAdminToken?: string;
66
+ /**
67
+ * Minutes a materialized credential file may linger on this host (source:
68
+ * 'hub'). Materialized files are a TTL-bound cache of hub content, never a
69
+ * permanent copy — resolve re-materializes on demand, so expiry is
70
+ * transparent to consumers. Startup sweeps everything (a restart clears
71
+ * the grant ledger; cached rw material must not outlive it).
72
+ */
73
+ materializeTtlMinutes?: number;
74
+ /**
75
+ * Cache root for hub-mode materialized files (source: 'hub'; default
76
+ * `~/.dsh-ops/hub-cache`). Deliberately separate from credentialsDir: the
77
+ * sweeper only ever walks this dir, so files the yaml registry references
78
+ * (the documented fallback) are never touched.
79
+ */
80
+ hubCacheDir?: string;
49
81
  }
50
82
  export declare const Config: z<Config>;
51
83
  export type { AccessProvider, AccessProfile, EntryEnvelope, ProbeState, AdminTierStatus, AdminEntry, KindDescriptor, AccessAgent, AccessBrokerDecision, AccessBroker, OpsAccess, } from './types.js';