@elinpf/dsh-ops-access 0.2.0 → 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.
- package/lib/backend.js +227 -0
- package/lib/hub-backend.js +268 -0
- package/package.json +2 -5
package/lib/backend.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential-source backends for the ops-access seam.
|
|
3
|
+
*
|
|
4
|
+
* The `OpsAccess` handle (index.ts) owns policy — broker decisions, profile
|
|
5
|
+
* validation via provider schemas, envelope merge semantics, probes — and
|
|
6
|
+
* delegates raw entry persistence to an `AccessBackend`. Two backends exist:
|
|
7
|
+
*
|
|
8
|
+
* - `YamlBackend` — the original local YAML registry file (default).
|
|
9
|
+
* - `HubBackend` (hub-backend.ts) — a remote ops-access-hub service; secret
|
|
10
|
+
* content is fetched per resolve and materialized to managed local files.
|
|
11
|
+
*
|
|
12
|
+
* Both speak the same provider-shaped language: file fields are LOCAL PATHS
|
|
13
|
+
* in `fields` (the hub backend converts content ↔ path at its boundary), the
|
|
14
|
+
* envelope (`name`/`description`/`environment`) is per-entry, and a `probe`
|
|
15
|
+
* may ride beside each tier.
|
|
16
|
+
*
|
|
17
|
+
* @module @elinpf/dsh-ops-access/backend
|
|
18
|
+
*/
|
|
19
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
21
|
+
// ── Shared helpers ───────────────────────────────────────────────────────────
|
|
22
|
+
export function isPlainObject(value) {
|
|
23
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
/** Build an EntryEnvelope from raw entry data, taking each envelope field from the first source that has it. */
|
|
26
|
+
export function buildEnvelope(sources) {
|
|
27
|
+
const envelope = {};
|
|
28
|
+
for (const source of sources) {
|
|
29
|
+
if (!isPlainObject(source))
|
|
30
|
+
continue;
|
|
31
|
+
if (envelope.name === undefined && typeof source.name === 'string')
|
|
32
|
+
envelope.name = source.name;
|
|
33
|
+
if (envelope.description === undefined && typeof source.description === 'string')
|
|
34
|
+
envelope.description = source.description;
|
|
35
|
+
if (envelope.environment === undefined && typeof source.environment === 'string')
|
|
36
|
+
envelope.environment = source.environment;
|
|
37
|
+
}
|
|
38
|
+
return envelope;
|
|
39
|
+
}
|
|
40
|
+
/** Read a persisted probe result off a raw tier object (durable boundary — sanitize). */
|
|
41
|
+
export function probeOf(tierRaw) {
|
|
42
|
+
if (!isPlainObject(tierRaw))
|
|
43
|
+
return undefined;
|
|
44
|
+
const p = tierRaw.probe;
|
|
45
|
+
if (!isPlainObject(p))
|
|
46
|
+
return undefined;
|
|
47
|
+
const probe = p;
|
|
48
|
+
if (probe.status !== 'verified' && probe.status !== 'mismatch' && probe.status !== 'unverifiable')
|
|
49
|
+
return undefined;
|
|
50
|
+
if (typeof probe.probedAt !== 'string')
|
|
51
|
+
return undefined;
|
|
52
|
+
const out = { status: probe.status, probedAt: probe.probedAt };
|
|
53
|
+
if (typeof probe.detail === 'string')
|
|
54
|
+
out.detail = probe.detail;
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Apply the envelope patch discipline to a mutable target: undefined field =
|
|
59
|
+
* preserve, empty string = delete, else set. Shared by both backends so the
|
|
60
|
+
* merge semantics stay identical across sources.
|
|
61
|
+
*/
|
|
62
|
+
export function applyEnvelopePatch(target, envelope) {
|
|
63
|
+
if (envelope === undefined)
|
|
64
|
+
return;
|
|
65
|
+
for (const key of ['name', 'description', 'environment']) {
|
|
66
|
+
const value = envelope[key];
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
continue;
|
|
69
|
+
if (value === '')
|
|
70
|
+
delete target[key];
|
|
71
|
+
else
|
|
72
|
+
target[key] = value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** Merge an envelope patch over an existing envelope, returning the result (hub backend variant). */
|
|
76
|
+
export function mergeEnvelope(existing, patch) {
|
|
77
|
+
const target = { ...existing };
|
|
78
|
+
applyEnvelopePatch(target, patch);
|
|
79
|
+
return buildEnvelope([target]);
|
|
80
|
+
}
|
|
81
|
+
/** Thrown when the source itself is unavailable (yaml: registry file missing) — resolve surfaces it verbatim. */
|
|
82
|
+
export class SourceUnavailableError extends Error {
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Read and parse the registry file. Returns null when the file does not
|
|
86
|
+
* exist so callers can pick their own discipline (list → empty, resolve →
|
|
87
|
+
* error). Never includes raw file text in errors.
|
|
88
|
+
*/
|
|
89
|
+
async function loadRegistry(file) {
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = await readFile(file, 'utf8');
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err?.code === 'ENOENT')
|
|
96
|
+
return null;
|
|
97
|
+
throw new Error(`ops-access: failed to read registry file ${file}: ${err?.message ?? err}`);
|
|
98
|
+
}
|
|
99
|
+
let doc;
|
|
100
|
+
try {
|
|
101
|
+
doc = parseYaml(text);
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
// First line only — the yaml library appends a source snippet to its
|
|
105
|
+
// messages, and raw registry text must not leak into errors.
|
|
106
|
+
const summary = String(err?.message ?? err).split('\n')[0];
|
|
107
|
+
throw new Error(`ops-access: failed to parse registry file ${file}: ${summary}`);
|
|
108
|
+
}
|
|
109
|
+
// An empty file parses to null — treat it as an empty registry.
|
|
110
|
+
if (doc == null)
|
|
111
|
+
return {};
|
|
112
|
+
if (!isPlainObject(doc)) {
|
|
113
|
+
throw new Error(`ops-access: registry file ${file} must contain a top-level mapping`);
|
|
114
|
+
}
|
|
115
|
+
const registry = {};
|
|
116
|
+
for (const [kind, section] of Object.entries(doc)) {
|
|
117
|
+
if (kind === 'version')
|
|
118
|
+
continue;
|
|
119
|
+
if (!isPlainObject(section)) {
|
|
120
|
+
throw new Error(`ops-access: section "${kind}" in registry file ${file} must be a mapping of profile names`);
|
|
121
|
+
}
|
|
122
|
+
registry[kind] = section;
|
|
123
|
+
}
|
|
124
|
+
return registry;
|
|
125
|
+
}
|
|
126
|
+
/** Serialize a registry back to its YAML file with the version header. */
|
|
127
|
+
async function saveRegistry(file, registry) {
|
|
128
|
+
const doc = { version: 1 };
|
|
129
|
+
for (const [kind, section] of Object.entries(registry)) {
|
|
130
|
+
doc[kind] = section;
|
|
131
|
+
}
|
|
132
|
+
await writeFile(file, stringifyYaml(doc), 'utf8');
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The original local YAML registry. Every operation re-reads (and writes
|
|
136
|
+
* back) the whole file — edits take effect immediately, nothing is cached.
|
|
137
|
+
* Sections whose kind has no registered provider are preserved untouched.
|
|
138
|
+
*/
|
|
139
|
+
export class YamlBackend {
|
|
140
|
+
registryFile;
|
|
141
|
+
label;
|
|
142
|
+
constructor(registryFile) {
|
|
143
|
+
this.registryFile = registryFile;
|
|
144
|
+
this.label = `registry file ${registryFile}`;
|
|
145
|
+
}
|
|
146
|
+
async listEntries() {
|
|
147
|
+
const registry = await loadRegistry(this.registryFile);
|
|
148
|
+
if (registry === null)
|
|
149
|
+
return [];
|
|
150
|
+
const result = [];
|
|
151
|
+
for (const kind of Object.keys(registry).sort()) {
|
|
152
|
+
const section = registry[kind];
|
|
153
|
+
for (const name of Object.keys(section).sort()) {
|
|
154
|
+
const entry = section[name];
|
|
155
|
+
if (!isPlainObject(entry))
|
|
156
|
+
continue;
|
|
157
|
+
const raw = entry;
|
|
158
|
+
const tiers = {};
|
|
159
|
+
if (isPlainObject(raw.ro))
|
|
160
|
+
tiers.ro = { ...(probeOf(raw.ro) !== undefined ? { probe: probeOf(raw.ro) } : {}) };
|
|
161
|
+
if (isPlainObject(raw.rw))
|
|
162
|
+
tiers.rw = { ...(probeOf(raw.rw) !== undefined ? { probe: probeOf(raw.rw) } : {}) };
|
|
163
|
+
result.push({ kind, name, envelope: buildEnvelope([raw]), tiers });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
167
|
+
}
|
|
168
|
+
async loadTier(kind, name, tier) {
|
|
169
|
+
const registry = await loadRegistry(this.registryFile);
|
|
170
|
+
if (registry === null) {
|
|
171
|
+
throw new SourceUnavailableError(`ops-access: registry file not found: ${this.registryFile}`);
|
|
172
|
+
}
|
|
173
|
+
const entry = registry[kind]?.[name];
|
|
174
|
+
if (!isPlainObject(entry))
|
|
175
|
+
return null;
|
|
176
|
+
const raw = entry[tier];
|
|
177
|
+
if (!isPlainObject(raw))
|
|
178
|
+
return null;
|
|
179
|
+
const fields = { ...raw };
|
|
180
|
+
const probe = probeOf(raw);
|
|
181
|
+
delete fields.probe;
|
|
182
|
+
return { fields, envelope: buildEnvelope([entry]), ...(probe !== undefined ? { probe } : {}) };
|
|
183
|
+
}
|
|
184
|
+
async putTier(kind, name, tier, fields, envelope, probe) {
|
|
185
|
+
// Read → merge → write back. A missing file starts from an empty
|
|
186
|
+
// registry; an unparseable file throws (we will not overwrite a file we
|
|
187
|
+
// cannot read).
|
|
188
|
+
let registry = {};
|
|
189
|
+
const loaded = await loadRegistry(this.registryFile);
|
|
190
|
+
if (loaded !== null)
|
|
191
|
+
registry = loaded;
|
|
192
|
+
if (!registry[kind])
|
|
193
|
+
registry[kind] = {};
|
|
194
|
+
if (!isPlainObject(registry[kind][name]))
|
|
195
|
+
registry[kind][name] = {};
|
|
196
|
+
const entry = registry[kind][name];
|
|
197
|
+
const tierData = { ...fields };
|
|
198
|
+
if (probe !== undefined)
|
|
199
|
+
tierData.probe = probe;
|
|
200
|
+
entry[tier] = tierData;
|
|
201
|
+
applyEnvelopePatch(entry, envelope);
|
|
202
|
+
await saveRegistry(this.registryFile, registry);
|
|
203
|
+
}
|
|
204
|
+
async deleteTier(kind, name, tier) {
|
|
205
|
+
const registry = await loadRegistry(this.registryFile);
|
|
206
|
+
if (registry === null)
|
|
207
|
+
return 'missing';
|
|
208
|
+
const section = registry[kind];
|
|
209
|
+
if (!section || !(name in section))
|
|
210
|
+
return 'missing';
|
|
211
|
+
const entry = section[name];
|
|
212
|
+
if (!isPlainObject(entry))
|
|
213
|
+
return 'missing';
|
|
214
|
+
delete entry[tier];
|
|
215
|
+
// If neither tier remains, drop the whole entry and empty sections.
|
|
216
|
+
const remaining = ['ro', 'rw'].filter((t) => entry[t] !== undefined);
|
|
217
|
+
if (remaining.length > 0) {
|
|
218
|
+
await saveRegistry(this.registryFile, registry);
|
|
219
|
+
return 'tier';
|
|
220
|
+
}
|
|
221
|
+
delete section[name];
|
|
222
|
+
if (Object.keys(section).length === 0)
|
|
223
|
+
delete registry[kind];
|
|
224
|
+
await saveRegistry(this.registryFile, registry);
|
|
225
|
+
return 'entry';
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -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/package.json
CHANGED
|
@@ -1,15 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elinpf/dsh-ops-access",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Ops access capability seam — owns the YAML credential registry and exposes ctx.opsAccess (resolve/list/register) to provider plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
8
8
|
"files": [
|
|
9
|
-
"lib
|
|
10
|
-
"lib/invariant.js",
|
|
11
|
-
"lib/types.js",
|
|
12
|
-
"lib/mention.js",
|
|
9
|
+
"lib/**/*.js",
|
|
13
10
|
"lib/**/*.d.ts",
|
|
14
11
|
"cordis.patch.yml"
|
|
15
12
|
],
|