akv-manager 0.1.1 → 0.3.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/server.js CHANGED
@@ -88,23 +88,43 @@ const ARM_RES = 'https://management.azure.com';
88
88
  // `az account get-access-token` is the ONLY remaining az call on the hot path —
89
89
  // it bridges the existing az login (MFA / conditional access) into a bearer token;
90
90
  // everything else below is plain fetch against Azure REST.
91
- const tokenCache = new Map(); // resource -> { token, exp, pending }
92
-
93
- async function getToken(resource) {
94
- const c = tokenCache.get(resource);
91
+ const tokenCache = new Map(); // `${tenant}|${resource}` -> { token, exp, pending }
92
+ let selectedSub = null; // active subscription in single-account mode; scopes tokens to its user+tenant
93
+ let aggregateMode = false; // "All accounts": fan out across every logged-in tenant and merge results
94
+ const subTenant = new Map(); // subscriptionId -> tenantId, populated by azAccountList (for token keying)
95
+
96
+ // Cache/mint an access token for `resource`, scoped to a subscription's tenant. `sub`
97
+ // picks the tenant (falls back to selectedSub, else the CLI default); tokens are keyed
98
+ // by tenant so subscriptions sharing a tenant reuse one token (fewer slow `az` calls).
99
+ async function getToken(resource, sub) {
100
+ const scope = sub || selectedSub; // subscription id, or null → CLI default
101
+ const tenant = scope ? (subTenant.get(scope) || scope) : 'default';
102
+ const key = `${tenant}|${resource}`;
103
+ const c = tokenCache.get(key);
95
104
  if (c && c.token && Date.now() < c.exp) return c.token;
96
105
  if (c && c.pending) return c.pending;
97
106
  const pending = (async () => {
98
- const out = await az(['account', 'get-access-token', '--resource', resource]);
99
- tokenCache.set(resource, { token: out.accessToken, exp: Date.now() + 45 * 60 * 1000, pending: null });
107
+ // Scope the token to a subscription so it carries that subscription's user + tenant.
108
+ // This is how switching account — and "All accounts" mode, which mints one token per
109
+ // tenant — works without mutating the CLI's global default (no `az account set`).
110
+ const args = ['account', 'get-access-token', '--resource', resource];
111
+ if (scope) args.push('--subscription', scope);
112
+ const out = await az(args);
113
+ tokenCache.set(key, { token: out.accessToken, exp: Date.now() + 45 * 60 * 1000, pending: null });
100
114
  return out.accessToken;
101
115
  })();
102
- tokenCache.set(resource, { ...(c || {}), pending });
116
+ tokenCache.set(key, { ...(c || {}), pending });
103
117
  try { return await pending; }
104
- catch (e) { tokenCache.delete(resource); throw e; }
118
+ catch (e) { tokenCache.delete(key); throw e; }
119
+ }
120
+ // Drop a cached token (on 401) for the tenant behind a given subscription.
121
+ function dropToken(resource, sub) {
122
+ const scope = sub || selectedSub;
123
+ const tenant = scope ? (subTenant.get(scope) || scope) : 'default';
124
+ tokenCache.delete(`${tenant}|${resource}`);
105
125
  }
106
- const getVaultToken = () => getToken(VAULT_RES);
107
- const getArmToken = () => getToken(ARM_RES);
126
+ const getVaultToken = (sub) => getToken(VAULT_RES, sub);
127
+ const getArmToken = (sub) => getToken(ARM_RES, sub);
108
128
 
109
129
  function decodeJwt(t) {
110
130
  try {
@@ -114,8 +134,8 @@ function decodeJwt(t) {
114
134
  }
115
135
 
116
136
  // Azure Resource Manager REST call (management plane).
117
- async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000 } = {}) {
118
- const token = await getArmToken();
137
+ async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000, sub } = {}) {
138
+ const token = await getArmToken(sub);
119
139
  const url = pathOrUrl.startsWith('http') ? pathOrUrl : `${ARM_RES}${pathOrUrl}`;
120
140
  const ctrl = new AbortController();
121
141
  const timer = setTimeout(() => ctrl.abort(), timeout);
@@ -130,7 +150,7 @@ async function armFetch(pathOrUrl, { method = 'GET', body, timeout = 40000 } = {
130
150
  } catch (e) {
131
151
  throw Object.assign(new Error(e.name === 'AbortError' ? 'ARM request timed out' : e.message), { timedOut: e.name === 'AbortError' });
132
152
  } finally { clearTimeout(timer); }
133
- if (resp.status === 401) { tokenCache.delete(ARM_RES); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
153
+ if (resp.status === 401) { dropToken(ARM_RES, sub); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
134
154
  if (resp.status === 403) throw Object.assign(new Error('Forbidden'), { forbidden: true });
135
155
  if (!resp.ok) throw new Error(`ARM ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
136
156
  return resp.json();
@@ -145,7 +165,48 @@ async function listSubscriptions(force) {
145
165
  subsCache = { at: Date.now(), data };
146
166
  return data;
147
167
  }
148
- let selectedSub = null; // active subscription (server-side context, replaces `az account set`)
168
+
169
+ // All identities currently logged into the Azure CLI (every `az login`), across all
170
+ // tenants — the tenant-independent master list behind the account switcher. Unlike
171
+ // listSubscriptions (ARM, single tenant), this reads local CLI state, so it still
172
+ // works when the *active* account's token has expired.
173
+ let accountsCache = { at: 0, data: null };
174
+ const ACCOUNTS_TTL = 30 * 1000;
175
+ async function azAccountList(force) {
176
+ if (!force && accountsCache.data && Date.now() - accountsCache.at < ACCOUNTS_TTL) return accountsCache.data;
177
+ const out = await az(['account', 'list']);
178
+ const data = (Array.isArray(out) ? out : []).map((a) => ({
179
+ id: a.id, name: a.name, state: a.state, tenantId: a.tenantId, isDefault: !!a.isDefault,
180
+ user: (a.user && a.user.name) || 'unknown', type: (a.user && a.user.type) || 'user',
181
+ }));
182
+ accountsCache = { at: Date.now(), data };
183
+ for (const a of data) subTenant.set(a.id, a.tenantId); // keep the sub→tenant map warm for token keying
184
+ return data;
185
+ }
186
+ // The account the console is pointed at: the selected sub, else the CLI default, else the first.
187
+ function pickActiveAccount(accounts) {
188
+ return accounts.find((a) => a.id === selectedSub) || accounts.find((a) => a.isDefault) || accounts[0] || null;
189
+ }
190
+ // Distinct tenants across the logged-in accounts, each with its subscription ids and a
191
+ // representative sub used to mint that tenant's token. Drives "All accounts" fan-out.
192
+ function tenantsOf(accounts) {
193
+ const byTenant = new Map();
194
+ for (const a of accounts) {
195
+ if (!byTenant.has(a.tenantId)) byTenant.set(a.tenantId, { tenantId: a.tenantId, repSub: a.id, subIds: [], user: a.user });
196
+ byTenant.get(a.tenantId).subIds.push(a.id);
197
+ }
198
+ return [...byTenant.values()];
199
+ }
200
+ // Everything that depends on *which* identity is active. Cleared when the account or
201
+ // aggregate mode changes so the next calls re-auth and re-inventory as the new scope.
202
+ function resetIdentityCaches() {
203
+ tokenCache.clear();
204
+ subsCache = { at: 0, data: null };
205
+ vaultCache = { at: 0, data: null };
206
+ aksDiscoverCache = { at: 0, data: null };
207
+ listCache.clear();
208
+ aksCredCache.clear();
209
+ }
149
210
 
150
211
  // Tell apart a real permission denial from a Key Vault firewall / network block.
151
212
  async function forbiddenError(resp) {
@@ -155,8 +216,16 @@ async function forbiddenError(resp) {
155
216
  return Object.assign(new Error(msg), { forbidden: true, firewall });
156
217
  }
157
218
 
158
- async function kvFetch(name, pathQuery, { timeout = 15000 } = {}) {
159
- const token = await getVaultToken();
219
+ // Which subscription (hence tenant) a vault lives in — lets data-plane calls pick the
220
+ // right token in "All accounts" mode. Reads the cached inventory, so it's a cheap lookup.
221
+ async function vaultSub(name) {
222
+ try { const m = await findVaultMeta(name); return m ? m.subscriptionId : null; }
223
+ catch { return null; }
224
+ }
225
+
226
+ async function kvFetch(name, pathQuery, { timeout = 15000, sub } = {}) {
227
+ const scope = sub || await vaultSub(name);
228
+ const token = await getVaultToken(scope);
160
229
  const ctrl = new AbortController();
161
230
  const timer = setTimeout(() => ctrl.abort(), timeout);
162
231
  let resp;
@@ -166,7 +235,7 @@ async function kvFetch(name, pathQuery, { timeout = 15000 } = {}) {
166
235
  } catch (e) {
167
236
  throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), { timedOut: e.name === 'AbortError' });
168
237
  } finally { clearTimeout(timer); }
169
- if (resp.status === 401) { tokenCache.delete(VAULT_RES);
238
+ if (resp.status === 401) { dropToken(VAULT_RES, scope);
170
239
  throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
171
240
  if (resp.status === 403) throw await forbiddenError(resp);
172
241
  if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
@@ -218,21 +287,42 @@ async function kvListCached(name, kind) {
218
287
 
219
288
  // ---- API: accounts & subscriptions ----------------------------------------
220
289
 
290
+ // Active identity. Sourced from `az account list` (so it resolves even when the
291
+ // token is expired), then a liveness probe tells the UI whether re-login is needed.
221
292
  app.get('/api/account', (req, res) =>
222
293
  send(res, (async () => {
223
- const claims = decodeJwt(await getArmToken());
224
- const subs = await listSubscriptions();
225
- if (!selectedSub && subs.length) selectedSub = subs[0].subscriptionId;
226
- const cur = subs.find((s) => s.subscriptionId === selectedSub) || subs[0] || {};
294
+ const accounts = await azAccountList();
295
+ if (!accounts.length) throw Object.assign(new Error('Azure CLI is signed out — run az login'), { needLogin: true });
296
+ if (aggregateMode) {
297
+ const users = new Set(accounts.map((a) => a.user)), tenants = new Set(accounts.map((a) => a.tenantId));
298
+ return {
299
+ aggregate: true, user: { name: `All accounts · ${users.size} user${users.size === 1 ? '' : 's'}` },
300
+ name: `${accounts.length} subscriptions · ${tenants.size} tenant${tenants.size === 1 ? '' : 's'}`,
301
+ id: '', tenantId: '', live: true, needLogin: false,
302
+ };
303
+ }
304
+ const act = pickActiveAccount(accounts);
305
+ if (act && !selectedSub) selectedSub = act.id;
306
+ let live = false, tenantId = (act && act.tenantId) || '';
307
+ try { const claims = decodeJwt(await getArmToken()); live = true; if (claims.tid) tenantId = claims.tid; }
308
+ catch { /* token expired / conditional access → surface needLogin but keep identity */ }
227
309
  return {
228
- user: { name: claims.upn || claims.unique_name || claims.name || claims.email || 'signed in' },
229
- name: cur.displayName || '', id: cur.subscriptionId || '',
230
- tenantId: claims.tid || cur.tenantId || '',
310
+ aggregate: false,
311
+ user: { name: (act && act.user) || 'signed in' },
312
+ name: (act && act.name) || '', id: (act && act.id) || '',
313
+ tenantId, live, needLogin: !live,
231
314
  };
232
315
  })()));
233
316
 
234
317
  app.get('/api/subscriptions', (req, res) =>
235
318
  send(res, (async () => {
319
+ // In "All accounts" mode a single ARM token only sees one tenant, so list every
320
+ // logged-in subscription (all tenants) from the CLI instead.
321
+ if (aggregateMode) {
322
+ return (await azAccountList(!!req.query.refresh)).map((a) => ({
323
+ id: a.id, name: a.name, state: a.state, tenantId: a.tenantId, isDefault: false,
324
+ }));
325
+ }
236
326
  const subs = await listSubscriptions(!!req.query.refresh);
237
327
  if (!selectedSub && subs.length) selectedSub = subs[0].subscriptionId;
238
328
  return subs.map((s) => ({
@@ -241,24 +331,64 @@ app.get('/api/subscriptions', (req, res) =>
241
331
  }));
242
332
  })()));
243
333
 
334
+ // Every logged-in identity, grouped by user — powers the account switcher. Needs no
335
+ // valid token, so it stays reachable even when the active account has expired.
336
+ app.get('/api/accounts', (req, res) =>
337
+ send(res, (async () => {
338
+ const accounts = await azAccountList(!!req.query.refresh);
339
+ const byUser = new Map();
340
+ for (const a of accounts) {
341
+ if (!byUser.has(a.user)) byUser.set(a.user, { user: a.user, type: a.type, subscriptions: [] });
342
+ byUser.get(a.user).subscriptions.push({
343
+ id: a.id, name: a.name, state: a.state, tenantId: a.tenantId,
344
+ cliDefault: a.isDefault, active: a.id === selectedSub,
345
+ });
346
+ }
347
+ return { activeSubscription: selectedSub, aggregate: aggregateMode, users: [...byUser.values()] };
348
+ })()));
349
+
350
+ // Point the console at another subscription — which may belong to a different user /
351
+ // tenant. Because tokens are scoped to `selectedSub`, this is exactly what "change
352
+ // user" does: drop the identity-scoped caches so the next calls re-auth as the new
353
+ // identity. Validated against `az account list` so cross-tenant subs are allowed.
244
354
  app.post('/api/subscription/set', (req, res) => {
245
- const { subscription } = req.body || {};
355
+ const { subscription, all } = req.body || {};
356
+ // "All accounts": aggregate across every logged-in tenant (one token per tenant, merged).
357
+ if (all) {
358
+ return send(res, (async () => {
359
+ const accounts = await azAccountList();
360
+ if (!accounts.length) throw Object.assign(new Error('No logged-in accounts — run az login'), { needLogin: true });
361
+ aggregateMode = true;
362
+ selectedSub = null;
363
+ resetIdentityCaches();
364
+ const users = new Set(accounts.map((a) => a.user)), tenants = new Set(accounts.map((a) => a.tenantId));
365
+ return { aggregate: true, user: { name: `All accounts · ${users.size} user${users.size === 1 ? '' : 's'}` },
366
+ name: `${accounts.length} subscriptions · ${tenants.size} tenant${tenants.size === 1 ? '' : 's'}`,
367
+ subscriptions: accounts.length, tenants: tenants.size, live: true, needLogin: false };
368
+ })());
369
+ }
246
370
  if (!subscription || !SUB_RE.test(subscription))
247
371
  return res.status(400).json({ ok: false, error: 'Invalid subscription id/name' });
248
- // With everything on REST, the "active subscription" is just server state.
249
372
  send(res, (async () => {
250
- const subs = await listSubscriptions();
251
- const cur = subs.find((s) => s.subscriptionId === subscription);
252
- if (!cur) throw new Error('Subscription not found');
253
- selectedSub = subscription;
254
- const claims = decodeJwt(await getArmToken());
255
- return { user: { name: claims.upn || claims.unique_name || claims.name || 'signed in' },
256
- name: cur.displayName, id: cur.subscriptionId, tenantId: claims.tid || cur.tenantId };
373
+ const accounts = await azAccountList();
374
+ const act = accounts.find((a) => a.id === subscription || a.name === subscription);
375
+ if (!act) throw Object.assign(new Error('Subscription not found among logged-in accounts'), { status: 404 });
376
+ aggregateMode = false;
377
+ selectedSub = act.id;
378
+ resetIdentityCaches(); // identity changed → drop identity-scoped caches
379
+ let live = false;
380
+ try { await getArmToken(); live = true; } catch { /* target account needs re-login */ }
381
+ return {
382
+ aggregate: false,
383
+ user: { name: act.user }, name: act.name, id: act.id, tenantId: act.tenantId,
384
+ live, needLogin: !live, loginHint: live ? null : `az login --tenant ${act.tenantId}`,
385
+ };
257
386
  })());
258
387
  });
259
388
 
260
389
  app.post('/api/logout', (req, res) => {
261
- tokenCache.clear(); subsCache = { at: 0, data: null }; vaultCache = { at: 0, data: null };
390
+ selectedSub = null; aggregateMode = false;
391
+ resetIdentityCaches(); accountsCache = { at: 0, data: null }; subTenant.clear();
262
392
  send(res, az(['logout'], { json: false }).then(() => ({ loggedOut: true })).catch(() => ({ loggedOut: true })));
263
393
  });
264
394
 
@@ -272,10 +402,9 @@ const VAULT_QUERY =
272
402
  "uri=tostring(properties.vaultUri), tags " +
273
403
  "| order by name asc";
274
404
 
275
- async function listVaultsRaw(force) {
276
- if (!force && vaultCache.data && Date.now() - vaultCache.at < VAULT_TTL) return vaultCache.data;
277
- const subIds = (await listSubscriptions(force)).map((s) => s.subscriptionId);
278
- // Resource Graph returns at most 1000 rows per page; follow $skipToken to get them all.
405
+ // One Resource Graph sweep over a set of subscriptions, using `sub`'s tenant token.
406
+ // Resource Graph returns at most 1000 rows per page; follow $skipToken to get them all.
407
+ async function resourceGraphVaults(subIds, sub) {
279
408
  const data = [];
280
409
  let skipToken;
281
410
  for (let page = 0; page < 100; page++) { // hard stop at 100k vaults — a safety bound
@@ -284,19 +413,47 @@ async function listVaultsRaw(force) {
284
413
  const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
285
414
  method: 'POST',
286
415
  body: { subscriptions: subIds, query: VAULT_QUERY, options },
416
+ sub,
287
417
  });
288
418
  if (out.data) data.push(...out.data);
289
419
  skipToken = out.$skipToken;
290
420
  if (!skipToken) break;
291
421
  }
292
- vaultCache = { at: Date.now(), data };
422
+ return data;
423
+ }
424
+
425
+ async function listVaultsRaw(force) {
426
+ if (!force && vaultCache.data && Date.now() - vaultCache.at < VAULT_TTL) return vaultCache.data;
427
+ const data = [];
428
+ const warnings = []; // accounts whose slice failed (e.g. expired) — surfaced as a partial-results note
429
+ if (aggregateMode) {
430
+ // "All accounts": one Resource Graph sweep per tenant (each needs its own token),
431
+ // tag every vault with its owning account, and keep going if one tenant fails.
432
+ for (const t of tenantsOf(await azAccountList(force))) {
433
+ try {
434
+ const rows = await resourceGraphVaults(t.subIds, t.repSub);
435
+ for (const r of rows) { r.tenantId = t.tenantId; r.account = t.user; }
436
+ data.push(...rows);
437
+ } catch (e) {
438
+ warnings.push({ tenantId: t.tenantId, user: t.user, needLogin: !!e.needLogin,
439
+ error: (e.stderr || e.message || 'failed').slice(0, 200) });
440
+ }
441
+ }
442
+ } else {
443
+ const subIds = (await listSubscriptions(force)).map((s) => s.subscriptionId);
444
+ const rows = await resourceGraphVaults(subIds, selectedSub);
445
+ const act = pickActiveAccount(await azAccountList().catch(() => [])) || {};
446
+ for (const r of rows) { r.tenantId = act.tenantId || ''; r.account = act.user || ''; }
447
+ data.push(...rows);
448
+ }
449
+ vaultCache = { at: Date.now(), data, warnings };
293
450
  return data;
294
451
  }
295
452
 
296
453
  app.get('/api/vaults', async (req, res) => {
297
454
  try {
298
455
  const data = await listVaultsRaw(!!req.query.refresh);
299
- res.json({ ok: true, data });
456
+ res.json({ ok: true, data, aggregate: aggregateMode, warnings: (vaultCache.warnings || []) });
300
457
  } catch (err) {
301
458
  res.status(err.needLogin ? 401 : 500)
302
459
  .json({ ok: false, error: err.stderr || err.message, needLogin: !!err.needLogin });
@@ -328,6 +485,25 @@ function tagMatch(pattern, key) {
328
485
  }
329
486
  function isProtectedTag(key) { return PROTECTED_TAGS.some((p) => tagMatch(p, key)); }
330
487
 
488
+ const IPV4_RE = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}(?:\/(?:3[0-2]|[12]?\d))?$/;
489
+ function normalizeIpRule(ip) {
490
+ const s = String(ip || '').trim();
491
+ if (!IPV4_RE.test(s)) throw Object.assign(new Error('Invalid IPv4 address/range'), { status: 400 });
492
+ return s.includes('/') ? s : `${s}/32`;
493
+ }
494
+ function ipRuleMatches(ruleValue, targetRule) {
495
+ const rv = String(ruleValue || '').trim().toLowerCase();
496
+ const t = String(targetRule || '').trim().toLowerCase();
497
+ if (!rv || !t) return false;
498
+ if (rv === t) return true;
499
+ if (t.endsWith('/32') && rv === t.slice(0, -3)) return true;
500
+ return false;
501
+ }
502
+ function parseFirewallIp(msg) {
503
+ const m = /Client address:\s*([0-9]{1,3}(?:\.[0-9]{1,3}){3})/i.exec(String(msg || ''));
504
+ return m ? m[1] : null;
505
+ }
506
+
331
507
  // Replace a vault's tag set (management plane). The body's `tags` object is the
332
508
  // COMPLETE desired set — Key Vault's PATCH overwrites the whole tags collection.
333
509
  // Protected/default tags are force-preserved from the current state, so they can be
@@ -356,12 +532,81 @@ app.put('/api/vaults/:name/tags', (req, res) => {
356
532
  for (const [k, val] of Object.entries(current)) { if (isProtectedTag(k)) finalTags[k] = val; }
357
533
  const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
358
534
  `/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
359
- const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags } });
535
+ const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags }, sub: v.subscriptionId });
360
536
  v.tags = (out && out.tags) || finalTags; // reflect immediately in the cached inventory
361
537
  return { tags: v.tags };
362
538
  })());
363
539
  });
364
540
 
541
+ // Allow one IPv4 (or CIDR) through a vault firewall by adding it to networkAcls.ipRules.
542
+ // Requires management-plane write on the vault (e.g. Contributor).
543
+ app.post('/api/vaults/:name/firewall/allow-ip', (req, res) => {
544
+ const { name } = req.params;
545
+ if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
546
+ send(res, (async () => {
547
+ const v = await findVaultMeta(name);
548
+ if (!v) throw Object.assign(new Error('Vault not found in inventory — try Refresh first'), { status: 404, notFound: true });
549
+
550
+ let ip = null;
551
+ try {
552
+ const bodyIp = req.body && req.body.ip;
553
+ ip = bodyIp ? String(bodyIp).trim() : null;
554
+ if (!ip) ip = parseFirewallIp(req.body && req.body.error);
555
+ ip = normalizeIpRule(ip || '');
556
+ } catch {
557
+ throw Object.assign(new Error('Could not determine a valid IP. Provide req.body.ip (IPv4)'), { status: 400 });
558
+ }
559
+
560
+ const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
561
+ `/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
562
+ const cur = await armFetch(url, { sub: v.subscriptionId });
563
+ const props = cur.properties || {};
564
+ const acl = props.networkAcls || {};
565
+ const currentRules = Array.isArray(acl.ipRules) ? acl.ipRules : [];
566
+ const have = currentRules.some((r) => String((r && r.value) || '').toLowerCase() === ip.toLowerCase());
567
+ if (!have) {
568
+ const nextAcls = { ...acl, ipRules: [...currentRules, { value: ip }] };
569
+ await armFetch(url, { method: 'PATCH', body: { properties: { networkAcls: nextAcls } }, sub: v.subscriptionId });
570
+ }
571
+ return { vault: name, ip, alreadyAllowed: have };
572
+ })());
573
+ });
574
+
575
+ // Remove one IPv4 (or CIDR) from a vault firewall (networkAcls.ipRules).
576
+ // Requires management-plane write on the vault (e.g. Contributor).
577
+ app.post('/api/vaults/:name/firewall/remove-ip', (req, res) => {
578
+ const { name } = req.params;
579
+ if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
580
+ send(res, (async () => {
581
+ const v = await findVaultMeta(name);
582
+ if (!v) throw Object.assign(new Error('Vault not found in inventory — try Refresh first'), { status: 404, notFound: true });
583
+
584
+ let ip;
585
+ try {
586
+ const bodyIp = req.body && req.body.ip;
587
+ ip = bodyIp ? String(bodyIp).trim() : null;
588
+ if (!ip) ip = parseFirewallIp(req.body && req.body.error);
589
+ ip = normalizeIpRule(ip || '');
590
+ } catch {
591
+ throw Object.assign(new Error('Could not determine a valid IP. Provide req.body.ip (IPv4)'), { status: 400 });
592
+ }
593
+
594
+ const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
595
+ `/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
596
+ const cur = await armFetch(url, { sub: v.subscriptionId });
597
+ const props = cur.properties || {};
598
+ const acl = props.networkAcls || {};
599
+ const currentRules = Array.isArray(acl.ipRules) ? acl.ipRules : [];
600
+ const nextRules = currentRules.filter((r) => !ipRuleMatches((r && r.value) || '', ip));
601
+ const removed = nextRules.length !== currentRules.length;
602
+ if (removed) {
603
+ const nextAcls = { ...acl, ipRules: nextRules };
604
+ await armFetch(url, { method: 'PATCH', body: { properties: { networkAcls: nextAcls } }, sub: v.subscriptionId });
605
+ }
606
+ return { vault: name, ip, removed };
607
+ })());
608
+ });
609
+
365
610
  // ---- API: expiring secrets & certificates (scans every readable vault) -----
366
611
 
367
612
  /** Run async worker over items with bounded concurrency. */
@@ -493,19 +738,24 @@ function buildAttrs(src = {}) {
493
738
  }
494
739
 
495
740
  async function kvWrite(vaultName, method, pathQuery, body) {
496
- const token = await getVaultToken();
741
+ const scope = await vaultSub(vaultName);
742
+ const token = await getVaultToken(scope);
497
743
  const ctrl = new AbortController();
498
744
  const timer = setTimeout(() => ctrl.abort(), 20000);
499
745
  let resp;
500
746
  try {
501
- resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, {
502
- method, headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
503
- body: JSON.stringify(body), signal: ctrl.signal,
504
- });
747
+ // DELETE carries no body — sending one (or a JSON content-type) is what makes
748
+ // some proxies reject the request, so build the init conditionally.
749
+ const init = { method, headers: { Authorization: `Bearer ${token}` }, signal: ctrl.signal };
750
+ if (body !== undefined) {
751
+ init.headers['Content-Type'] = 'application/json';
752
+ init.body = JSON.stringify(body);
753
+ }
754
+ resp = await fetch(`https://${vaultName.toLowerCase()}.vault.azure.net${pathQuery}`, init);
505
755
  } catch (e) {
506
756
  throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), {});
507
757
  } finally { clearTimeout(timer); }
508
- if (resp.status === 401) { tokenCache.delete(VAULT_RES); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
758
+ if (resp.status === 401) { dropToken(VAULT_RES, scope); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
509
759
  if (resp.status === 403) throw await forbiddenError(resp);
510
760
  if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
511
761
  if (!resp.ok) {
@@ -536,6 +786,22 @@ app.put('/api/vaults/:name/secrets/:secret', (req, res) => {
536
786
  send(res, kvWrite(name, 'PUT', `/secrets/${secret}?api-version=${KV_API}`, body));
537
787
  });
538
788
 
789
+ // Delete a secret. Soft-delete vaults keep it recoverable until its scheduled purge;
790
+ // purging is deliberately NOT exposed here — it is irreversible.
791
+ app.delete('/api/vaults/:name/secrets/:secret', async (req, res) => {
792
+ const { name, secret } = req.params;
793
+ if (!isValidName(name) || !isValidName(secret)) return res.status(400).json({ ok: false, error: 'Invalid name' });
794
+ try {
795
+ const d = await kvWrite(name, 'DELETE', `/secrets/${secret}?api-version=${KV_API}`);
796
+ res.json({ ok: true, data: { name: secret, ...deletedInfo(d) } });
797
+ } catch (e) {
798
+ res.status(e.needLogin ? 401 : e.forbidden ? 403 : e.notFound ? 404 : 500).json({
799
+ ok: false, error: writeReason(e, 'delete'),
800
+ needLogin: !!e.needLogin, forbidden: !!e.forbidden, firewall: !!e.firewall, notFound: !!e.notFound,
801
+ });
802
+ }
803
+ });
804
+
539
805
  // Update a certificate's attributes (enabled only — expiry is intrinsic to the cert).
540
806
  app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
541
807
  const { name, cert } = req.params;
@@ -548,12 +814,27 @@ app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
548
814
  // Turn a write failure into a precise, actionable reason. A 403 from Key Vault is
549
815
  // either a network/firewall block (fix: VPN) or a real data-plane permission gap
550
816
  // (fix: grant Set on secrets) — never conflate the two as a bare "forbidden".
551
- function writeReason(e) {
817
+ function writeReason(e, action = 'write') {
552
818
  if (e.firewall) return 'blocked by the vault firewall — your IP is not allowed (connect to the corporate VPN)';
553
- if (e.forbidden) return 'no write permission — your identity can read but not Set secrets on this vault';
819
+ if (e.forbidden) {
820
+ return action === 'delete'
821
+ ? 'no delete permission — your identity can read but not Delete secrets on this vault (needs secrets/delete, e.g. the Key Vault Secrets Officer role)'
822
+ : 'no write permission — your identity can read but not Set secrets on this vault';
823
+ }
554
824
  return e.message || 'failed';
555
825
  }
556
826
 
827
+ // A Key Vault DELETE returns a DeletedSecretBundle. On a soft-delete vault (the
828
+ // default, and mandatory since 2020) the secret is NOT erased — it moves to a
829
+ // recoverable state and Azure purges it on `scheduledPurgeDate` (unix seconds).
830
+ function deletedInfo(d) {
831
+ const ts = d && d.scheduledPurgeDate;
832
+ return {
833
+ recoverable: !!(d && d.recoveryId),
834
+ scheduledPurgeDate: typeof ts === 'number' ? new Date(ts * 1000).toISOString() : (ts || null),
835
+ };
836
+ }
837
+
557
838
  // Create one or many secrets in a vault. items: [{name, value}], optional common expires.
558
839
  app.post('/api/vaults/:name/secrets-create', async (req, res) => {
559
840
  const { name } = req.params;
@@ -576,16 +857,26 @@ app.post('/api/vaults/:name/secrets-create', async (req, res) => {
576
857
  res.json({ ok: true, results });
577
858
  });
578
859
 
579
- // Batch update across one or many vaults. items: [{vault, kind, name}], op: {enabled?, expires?}
860
+ // Batch update across one or many vaults. items: [{vault, kind, name}],
861
+ // op: {enabled?, expires?} or {delete: true} (secrets only).
580
862
  app.post('/api/batch', async (req, res) => {
581
863
  const { op = {}, items } = req.body || {};
582
864
  if (!Array.isArray(items) || !items.length) return res.status(400).json({ ok: false, error: 'No items selected' });
583
865
  if (items.length > 500) return res.status(400).json({ ok: false, error: 'Too many items (max 500 per batch)' });
866
+ const isDelete = op.delete === true;
584
867
  const attrs = buildAttrs(op);
585
868
  const results = await pool(items, async (it) => {
586
869
  const r = { vault: it.vault, name: it.name, kind: it.kind };
587
870
  try {
588
871
  if (!isValidName(it.vault) || !isValidName(it.name)) throw new Error('Invalid name');
872
+ if (isDelete) {
873
+ // Certificates and keys are out of scope on purpose: deleting a certificate
874
+ // also drops its managed secret/key pair, which is not what a "delete these
875
+ // rows" gesture in the secrets list should ever do.
876
+ if (it.kind !== 'secret') throw new Error('Only secrets can be deleted here');
877
+ const d = await kvWrite(it.vault, 'DELETE', `/secrets/${it.name}?api-version=${KV_API}`);
878
+ return { ...r, ok: true, ...deletedInfo(d) };
879
+ }
589
880
  if (it.kind === 'certificate') {
590
881
  if (op.expires) throw new Error("A certificate's expiry is fixed by the certificate itself");
591
882
  const a = {}; if (typeof op.enabled === 'boolean') a.enabled = op.enabled;
@@ -596,7 +887,7 @@ app.post('/api/batch', async (req, res) => {
596
887
  await kvWrite(it.vault, 'PATCH', `/secrets/${it.name}?api-version=${KV_API}`, { attributes: attrs });
597
888
  }
598
889
  return { ...r, ok: true };
599
- } catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
890
+ } catch (e) { return { ...r, ok: false, error: writeReason(e, isDelete ? 'delete' : 'write'), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
600
891
  }, 12);
601
892
  res.json({ ok: true, results });
602
893
  });
@@ -608,7 +899,7 @@ app.post('/api/batch', async (req, res) => {
608
899
 
609
900
  const https = require('https');
610
901
  const AKS_AAD_SERVER_APP = '6dae42f8-4368-4678-94ff-3960e28e3630'; // Microsoft public constant
611
- const getAksToken = () => getToken(AKS_AAD_SERVER_APP);
902
+ const getAksToken = (sub) => getToken(AKS_AAD_SERVER_APP, sub);
612
903
  const K8S_NAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/; // DNS-1123-ish (namespaces, pods, cms)
613
904
  const isK8sName = (v) => typeof v === 'string' && K8S_NAME.test(v);
614
905
 
@@ -654,14 +945,16 @@ async function aksCredential(c) {
654
945
  const url = `/subscriptions/${subId}/resourceGroups/${c.resourceGroup}` +
655
946
  `/providers/Microsoft.ContainerService/managedClusters/${c.name}` +
656
947
  `/listClusterUserCredential?api-version=2024-05-01`;
657
- const out = await armFetch(url, { method: 'POST' });
948
+ const out = await armFetch(url, { method: 'POST', sub: subId });
658
949
  const kc = out.kubeconfigs && out.kubeconfigs[0];
659
950
  if (!kc || !kc.value) throw new Error('No kubeconfig returned for this cluster');
660
951
  const yaml = Buffer.from(kc.value, 'base64').toString('utf8');
661
952
  const server = (yaml.match(/server:\s*(\S+)/) || [])[1];
662
953
  const caB64 = (yaml.match(/certificate-authority-data:\s*(\S+)/) || [])[1];
663
954
  if (!server) throw new Error('Could not parse API server URL from kubeconfig');
664
- const cred = { server: server.replace(/\/+$/, ''),
955
+ // Carry the cluster's subscription on the cred so k8sGet mints the right tenant's
956
+ // AKS token — this is what makes cross-tenant clusters work in "All accounts" mode.
957
+ const cred = { server: server.replace(/\/+$/, ''), sub: subId,
665
958
  ca: caB64 ? Buffer.from(caB64, 'base64').toString('utf8') : undefined, at: Date.now() };
666
959
  aksCredCache.set(key, cred);
667
960
  return cred;
@@ -678,22 +971,31 @@ function imageTag(img) {
678
971
  return c >= 0 ? repoTag.slice(c + 1) : '';
679
972
  }
680
973
 
681
- // One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
682
- function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
974
+ // One request against a cluster's Kubernetes API. raw=true returns text (for pod logs).
975
+ // Everything read-only goes through k8sGet; the single mutating caller is the workload
976
+ // restart, which PATCHes a controller's pod template. The AAD token is the user's own,
977
+ // so Kubernetes RBAC still decides what is allowed — this grants nothing they lack.
978
+ function k8sReq(cred, method, pathQuery, { raw = false, timeout = 25000, body, contentType } = {}) {
683
979
  return new Promise((resolve, reject) => {
684
- getAksToken().then((token) => {
980
+ getAksToken(cred.sub).then((token) => {
685
981
  const u = new URL(cred.server + pathQuery);
982
+ const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
983
+ const headers = { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' };
984
+ if (payload) {
985
+ headers['Content-Type'] = contentType || 'application/json';
986
+ headers['Content-Length'] = payload.length;
987
+ }
686
988
  const req = https.request({
687
- hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
989
+ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method,
688
990
  // '*/*' for raw so the log subresource returns text/plain without tripping content negotiation
689
- headers: { Authorization: `Bearer ${token}`, Accept: raw ? '*/*' : 'application/json' },
991
+ headers,
690
992
  ca: cred.ca, timeout,
691
993
  }, (res) => {
692
994
  let data = ''; res.setEncoding('utf8');
693
995
  res.on('data', (c) => { data += c; });
694
996
  res.on('end', () => {
695
997
  const s = res.statusCode;
696
- if (s === 401) { tokenCache.delete(AKS_AAD_SERVER_APP); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
998
+ if (s === 401) { dropToken(AKS_AAD_SERVER_APP, cred.sub); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
697
999
  if (s === 403) return reject(Object.assign(new Error(k8sMsg(data) || 'Forbidden'), { forbidden: true }));
698
1000
  if (s === 404) return reject(Object.assign(new Error(k8sMsg(data) || 'Not found'), { notFound: true }));
699
1001
  if (s >= 400) return reject(new Error(k8sMsg(data) || `Kubernetes returned ${s}`));
@@ -709,10 +1011,12 @@ function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
709
1011
  : e.message), { network }));
710
1012
  });
711
1013
  req.on('timeout', () => req.destroy(Object.assign(new Error('Cluster API request timed out — is the VPN connected?'), { network: true })));
1014
+ if (payload) req.write(payload);
712
1015
  req.end();
713
1016
  }).catch(reject);
714
1017
  });
715
1018
  }
1019
+ const k8sGet = (cred, pathQuery, opts) => k8sReq(cred, 'GET', pathQuery, opts);
716
1020
 
717
1021
  // Discover clusters you have ARM read on (best-effort) and merge with the configured list.
718
1022
  const AKS_QUERY =
@@ -720,20 +1024,37 @@ const AKS_QUERY =
720
1024
  "| project name, resourceGroup, subscriptionId, " +
721
1025
  "ver=tostring(properties.kubernetesVersion), " +
722
1026
  "priv=tostring(properties.apiServerAccessProfile.enablePrivateCluster) | order by name asc";
723
- async function discoverClusters() {
724
- try {
725
- const subIds = (await listSubscriptions()).map((s) => s.subscriptionId);
1027
+ // Cached so getClusterChecked (called on every pod/log/configmap request) doesn't
1028
+ // re-run a Resource Graph query each time. Force-refreshed via ?refresh=1.
1029
+ let aksDiscoverCache = { at: 0, data: null };
1030
+ const AKS_DISCOVER_TTL = 60 * 1000;
1031
+ async function discoverClusters(force) {
1032
+ if (!force && aksDiscoverCache.data && Date.now() - aksDiscoverCache.at < AKS_DISCOVER_TTL) return aksDiscoverCache.data;
1033
+ const query = async (subIds, sub) => {
726
1034
  const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
727
- method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
1035
+ method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } }, sub,
728
1036
  });
729
1037
  return (out.data || []).map((c) => ({ name: c.name, resourceGroup: c.resourceGroup,
730
1038
  subscription: c.subscriptionId, version: c.ver, private: c.priv === 'true', configured: false }));
731
- } catch { return []; }
1039
+ };
1040
+ try {
1041
+ const data = [];
1042
+ if (aggregateMode) {
1043
+ // one sweep per tenant; skip tenants that fail (e.g. expired) so the rest still list
1044
+ for (const t of tenantsOf(await azAccountList(force))) {
1045
+ try { data.push(...await query(t.subIds, t.repSub)); } catch { /* skip this tenant */ }
1046
+ }
1047
+ } else {
1048
+ data.push(...await query((await listSubscriptions()).map((s) => s.subscriptionId), selectedSub));
1049
+ }
1050
+ aksDiscoverCache = { at: Date.now(), data };
1051
+ return data;
1052
+ } catch { return aksDiscoverCache.data || []; } // on error, reuse last good result
732
1053
  }
733
1054
 
734
1055
  function clusterById(list, id) { return list.find((c) => c.name === id); }
735
- async function aksList() {
736
- const discovered = await discoverClusters();
1056
+ async function aksList(force) {
1057
+ const discovered = await discoverClusters(force);
737
1058
  const byKey = new Map();
738
1059
  for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
739
1060
  for (const c of [...AKS_CLUSTERS, ...SESSION_CLUSTERS]) { // configured (file then session) win + carry label/env
@@ -744,7 +1065,7 @@ async function aksList() {
744
1065
  }
745
1066
 
746
1067
  app.get('/api/aks/clusters', (req, res) =>
747
- send(res, aksList().then((clusters) => ({ clusters,
1068
+ send(res, aksList(!!req.query.refresh).then((clusters) => ({ clusters,
748
1069
  configured: AKS_CLUSTERS.length + SESSION_CLUSTERS.length, file: AKS_CLUSTERS.length, session: SESSION_CLUSTERS.length }))));
749
1070
 
750
1071
  // Register the user's pasted cluster list for this session (from their localStorage).
@@ -788,7 +1109,7 @@ app.get('/api/aks/:cluster/pods', (req, res) =>
788
1109
  const labels = p.metadata.labels || {};
789
1110
  const build = imageTag((spec[0] || {}).image) || labels['app.kubernetes.io/version'] || labels.version || labels['tags.datadoghq.com/version'] || '';
790
1111
  return { name: p.metadata.name, namespace: p.metadata.namespace, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
791
- build,
1112
+ build, labels, // labels: used client-side to match Service selectors → Ingress
792
1113
  restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
793
1114
  ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
794
1115
  containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
@@ -822,6 +1143,65 @@ app.get('/api/aks/:cluster/:ns/pods/:pod/detail', (req, res) => {
822
1143
  })());
823
1144
  });
824
1145
 
1146
+ // ---- AKS: restart a workload (the one mutating cluster operation) -----------
1147
+ // A pod is never restarted directly: killing a pod is a hard stop of one replica, and
1148
+ // for a bare (uncontrolled) pod it is unrecoverable. Instead we walk up to the pod's
1149
+ // controller and stamp its pod template with `kubectl.kubernetes.io/restartedAt` —
1150
+ // byte-for-byte what `kubectl rollout restart` does, so the controller rolls replicas
1151
+ // gracefully. Needs Kubernetes RBAC `patch` on that controller in that namespace.
1152
+ const RESTARTABLE = { Deployment: 'deployments', StatefulSet: 'statefulsets', DaemonSet: 'daemonsets' };
1153
+
1154
+ async function resolveWorkload(cred, ns, pod) {
1155
+ const p = await k8sGet(cred, `/api/v1/namespaces/${ns}/pods/${pod}`);
1156
+ const owner = ((p.metadata || {}).ownerReferences || []).find((o) => o.controller) ||
1157
+ ((p.metadata || {}).ownerReferences || [])[0];
1158
+ if (!owner) {
1159
+ throw Object.assign(new Error(
1160
+ 'This pod has no controller (it is a bare pod). Nothing would recreate it, so restarting is not offered.'), { status: 409 });
1161
+ }
1162
+ if (owner.kind === 'ReplicaSet') {
1163
+ const rs = await k8sGet(cred, `/apis/apps/v1/namespaces/${ns}/replicasets/${owner.name}`);
1164
+ const dep = ((rs.metadata || {}).ownerReferences || []).find((o) => o.kind === 'Deployment');
1165
+ if (!dep) {
1166
+ throw Object.assign(new Error(
1167
+ `This pod is owned by ReplicaSet "${owner.name}" with no Deployment above it — a bare ReplicaSet does not roll its pods on a template change.`), { status: 409 });
1168
+ }
1169
+ return { kind: 'Deployment', name: dep.name, plural: 'deployments' };
1170
+ }
1171
+ const plural = RESTARTABLE[owner.kind];
1172
+ if (!plural) {
1173
+ throw Object.assign(new Error(
1174
+ `This pod is owned by a ${owner.kind} ("${owner.name}"), which has no rolling-restart semantics.`), { status: 409 });
1175
+ }
1176
+ return { kind: owner.kind, name: owner.name, plural };
1177
+ }
1178
+
1179
+ // dryRun=1 resolves the owning workload WITHOUT touching it, so the UI can name the
1180
+ // thing in its confirmation instead of asking the user to trust a pod name.
1181
+ app.post('/api/aks/:cluster/:ns/pods/:pod/restart', (req, res) => {
1182
+ const { ns, pod } = req.params;
1183
+ if (!isK8sName(ns) || !isK8sName(pod)) return res.status(400).json({ ok: false, error: 'Invalid namespace/pod' });
1184
+ const dryRun = req.query.dryRun === '1' || req.query.dryRun === 'true';
1185
+ send(res, (async () => {
1186
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1187
+ const wl = await resolveWorkload(cred, ns, pod);
1188
+ if (dryRun) return { ...wl, namespace: ns, dryRun: true };
1189
+ const restartedAt = new Date().toISOString();
1190
+ const patch = { spec: { template: { metadata: { annotations: { 'kubectl.kubernetes.io/restartedAt': restartedAt } } } } };
1191
+ try {
1192
+ await k8sReq(cred, 'PATCH', `/apis/apps/v1/namespaces/${ns}/${wl.plural}/${wl.name}`, {
1193
+ body: patch, contentType: 'application/strategic-merge-patch+json',
1194
+ });
1195
+ } catch (e) {
1196
+ if (e.forbidden) {
1197
+ e.message = `no permission to patch ${wl.plural} in "${ns}" (Kubernetes RBAC) — ${e.message}`;
1198
+ }
1199
+ throw e;
1200
+ }
1201
+ return { ...wl, namespace: ns, restartedAt };
1202
+ })());
1203
+ });
1204
+
825
1205
  // Cluster-wide configmap list (all namespaces).
826
1206
  app.get('/api/aks/:cluster/configmaps', (req, res) =>
827
1207
  send(res, (async () => {
@@ -900,6 +1280,67 @@ async function fetchSpc(cred, ns, name) {
900
1280
  }
901
1281
  return null;
902
1282
  }
1283
+ // ---- External Secrets helpers (shared by the per-pod trace and the cluster map) -----
1284
+ function parseVaultHost(url) { const m = /([a-z0-9][a-z0-9-]{1,22}[a-z0-9])\.vault\.azure\./i.exec(String(url || '')); return m ? m[1].toLowerCase() : null; }
1285
+ function akvVaultOf(store) { const a = store && store.spec && store.spec.provider && store.spec.provider.azurekv; return a ? parseVaultHost(a.vaultUrl || a.vaultUri) : null; }
1286
+ // GET the first readable API version among candidates; tolerate not-installed (404) / no-access (403).
1287
+ async function k8sFirst(cred, paths) {
1288
+ for (const p of paths) { try { return await k8sGet(cred, p); } catch (e) { if (e.notFound || e.forbidden) continue; throw e; } }
1289
+ return null;
1290
+ }
1291
+ const ES_GROUP = 'external-secrets.io', CSI_GROUP = 'secrets-store.csi.x-k8s.io';
1292
+ const esPaths = (suffix) => [`/apis/${ES_GROUP}/v1/${suffix}`, `/apis/${ES_GROUP}/v1beta1/${suffix}`];
1293
+ const csiPaths = (suffix) => [`/apis/${CSI_GROUP}/v1/${suffix}`, `/apis/${CSI_GROUP}/v1alpha1/${suffix}`];
1294
+ // Resolve a secretStoreRef ({kind,name}) to its azurekv vault short-name (or null).
1295
+ async function storeRefVault(cred, ns, ref) {
1296
+ if (!ref || !ref.name) return null;
1297
+ const store = await k8sFirst(cred, ref.kind === 'ClusterSecretStore'
1298
+ ? esPaths(`clustersecretstores/${ref.name}`) : esPaths(`namespaces/${ns}/secretstores/${ref.name}`));
1299
+ return akvVaultOf(store);
1300
+ }
1301
+ // When listing SecretStores is denied but get-by-name isn't (common on locked-down
1302
+ // clusters), try these conventional store names. Override/extend for your naming via
1303
+ // AZBO_SECRETSTORE_NAMES (comma-separated).
1304
+ const CONVENTIONAL_STORES = (process.env.AZBO_SECRETSTORE_NAMES || 'secretstore-app,secretstore,azure-store,azurekv,azure-kv')
1305
+ .split(',').map((s) => s.trim()).filter(Boolean);
1306
+ // Vault short-names a namespace reads from, resolved directly from its External Secrets
1307
+ // config (SecretStores + ExternalSecrets) and CSI SecretProviderClasses. Mirrors
1308
+ // `kubectl get secretstore -n <ns>`, so it needs only namespaced read. Reports
1309
+ // `restricted` when RBAC blocks reading those CRDs (so the UI can say so, not "none").
1310
+ // With `deep`, also GETs conventional SecretStore names — works with get-only RBAC.
1311
+ async function namespaceVaults(cred, ns, { deep = false } = {}) {
1312
+ const set = new Set();
1313
+ let forbidden = false;
1314
+ const listItems = async (paths) => {
1315
+ for (const p of paths) {
1316
+ try { return (await k8sGet(cred, p)).items || []; }
1317
+ catch (e) { if (e.forbidden) { forbidden = true; return null; } if (e.notFound) continue; throw e; }
1318
+ }
1319
+ return [];
1320
+ };
1321
+ for (const it of (await listItems(esPaths(`namespaces/${ns}/secretstores`)) || [])) { const v = akvVaultOf(it); if (v) set.add(v); }
1322
+ for (const it of (await listItems(esPaths(`namespaces/${ns}/externalsecrets`)) || [])) {
1323
+ const v = await storeRefVault(cred, ns, (it.spec && it.spec.secretStoreRef) || {}); if (v) set.add(v);
1324
+ }
1325
+ for (const it of (await listItems(csiPaths(`namespaces/${ns}/secretproviderclasses`)) || [])) {
1326
+ const params = (it.spec && it.spec.parameters) || {};
1327
+ const kv = params.keyvaultName || params.keyvaultname || params.keyVaultName;
1328
+ if (kv) set.add(String(kv).toLowerCase());
1329
+ }
1330
+ // get-only RBAC fallback: fetch conventional SecretStore names directly by name.
1331
+ if (deep && !set.size) {
1332
+ for (const nm of CONVENTIONAL_STORES) {
1333
+ let store = null, stop = false;
1334
+ for (const p of esPaths(`namespaces/${ns}/secretstores/${nm}`)) {
1335
+ try { store = await k8sGet(cred, p); break; }
1336
+ catch (e) { if (e.forbidden) { forbidden = true; stop = true; break; } if (e.notFound) continue; throw e; }
1337
+ }
1338
+ if (stop) break;
1339
+ const v = akvVaultOf(store); if (v) set.add(v);
1340
+ }
1341
+ }
1342
+ return { vaults: [...set], restricted: forbidden && set.size === 0 };
1343
+ }
903
1344
 
904
1345
  app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
905
1346
  const { ns, pod } = req.params;
@@ -1004,8 +1445,100 @@ app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
1004
1445
  } catch (e) { entry.reason = e.forbidden ? 'needs read on externalsecrets / secretstores' : (e.message || 'unreadable'); }
1005
1446
  externalSecrets.push(entry);
1006
1447
  }
1448
+ // 5) Fallback that mirrors `kubectl get secretstore -n <ns>`: resolve the namespace's
1449
+ // own External Secrets config directly. Robust when the per-secret owner chain above
1450
+ // doesn't resolve (e.g. a frontend pod that never mounts the synced Secret).
1451
+ let nsInfo = { vaults: [], restricted: false };
1452
+ try { nsInfo = await namespaceVaults(cred, ns, { deep: true }); } catch { /* namespace CRDs not readable */ }
1453
+ for (const v of nsInfo.vaults) { set.add(v); (srcMap[v] = srcMap[v] || new Set()).add('namespace secretstore'); }
1454
+
1007
1455
  const vaults = [...set].sort().map((name) => ({ name, sources: [...(srcMap[name] || [])] }));
1008
- return { vaults, configmapsScanned: scanned, spcs, externalSecrets };
1456
+ return { vaults, configmapsScanned: scanned, spcs, externalSecrets, namespaceVaults: nsInfo.vaults, restricted: nsInfo.restricted };
1457
+ })());
1458
+ });
1459
+
1460
+ // Namespace → Key Vault map for the pod list. Resolves each namespace directly from its
1461
+ // own External Secrets config + CSI classes (namespaced reads — so it works with
1462
+ // per-namespace RBAC, unlike a cluster-wide list). The client passes ?namespaces= (the
1463
+ // namespaces its pods are in) so we only probe what's on screen.
1464
+ app.get('/api/aks/:cluster/secret-vaults', (req, res) => {
1465
+ send(res, (async () => {
1466
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1467
+ const nsList = [...new Set(String(req.query.namespaces || '').split(',')
1468
+ .map((s) => s.trim()).filter((s) => isK8sName(s)))].slice(0, 200); // bound the fan-out
1469
+ const byNamespace = {}, restricted = {};
1470
+ const results = await pool(nsList, async (ns) => [ns, await namespaceVaults(cred, ns)], 8);
1471
+ for (const r of results) {
1472
+ if (!r) continue;
1473
+ const [ns, info] = r;
1474
+ if (info.vaults.length) byNamespace[ns] = info.vaults.sort();
1475
+ else if (info.restricted) restricted[ns] = true;
1476
+ }
1477
+ return { byNamespace, restricted };
1478
+ })());
1479
+ });
1480
+
1481
+ const ingBackendSvc = (b) => (b && ((b.service && b.service.name) || b.serviceName)) || null;
1482
+ const ingBackendPort = (b) => { const s = b && b.service; const p = s && s.port; return (p && (p.name || p.number)) || (b && b.servicePort) || null; };
1483
+ // Parse an Ingress into the config worth showing: routes (host/path/backend:port), TLS
1484
+ // (secret + cert-manager/ACME), class, and the common nginx annotations (HTTPS redirect,
1485
+ // CORS, body size). Keeps only a curated set so the map stays lean.
1486
+ function parseIngress(ing) {
1487
+ const a = (ing.metadata && ing.metadata.annotations) || {};
1488
+ const spec = ing.spec || {};
1489
+ const rules = spec.rules || [];
1490
+ const routes = [];
1491
+ for (const r of rules) for (const p of ((r.http && r.http.paths) || []))
1492
+ routes.push({ host: r.host || null, path: p.path || '/', pathType: p.pathType || null, service: ingBackendSvc(p.backend), port: ingBackendPort(p.backend) });
1493
+ const cm = (a['cert-manager.io/common-name'] || a['kubernetes.io/tls-acme'] || a['cert-manager.io/cluster-issuer'] || a['cert-manager.io/issuer'] || a['certmanager.k8s.io/acme-challenge-type']) ? {
1494
+ commonName: a['cert-manager.io/common-name'] || null, tlsAcme: a['kubernetes.io/tls-acme'] || null,
1495
+ issuer: a['cert-manager.io/cluster-issuer'] || a['cert-manager.io/issuer'] || null,
1496
+ challenge: a['certmanager.k8s.io/acme-challenge-type'] || a['cert-manager.io/acme-challenge-type'] || null,
1497
+ dnsProvider: a['certmanager.k8s.io/acme-dns01-provider'] || a['cert-manager.io/acme-dns01-provider'] || null,
1498
+ } : null;
1499
+ const cors = a['nginx.ingress.kubernetes.io/enable-cors'] === 'true' ? {
1500
+ origin: a['nginx.ingress.kubernetes.io/cors-allow-origin'] || null,
1501
+ methods: a['nginx.ingress.kubernetes.io/cors-allow-methods'] || null,
1502
+ headers: a['nginx.ingress.kubernetes.io/cors-allow-headers'] || null,
1503
+ } : null;
1504
+ return {
1505
+ name: ing.metadata && ing.metadata.name,
1506
+ className: spec.ingressClassName || a['kubernetes.io/ingress.class'] || null,
1507
+ hosts: [...new Set(rules.map((r) => r.host).filter(Boolean))],
1508
+ routes,
1509
+ tls: (spec.tls || []).map((t) => ({ hosts: t.hosts || [], secretName: t.secretName || null })),
1510
+ sslRedirect: a['nginx.ingress.kubernetes.io/force-ssl-redirect'] === 'true' || a['nginx.org/redirect-to-https'] === 'true',
1511
+ allowHttp: a['kubernetes.io/ingress.allow-http'] || null,
1512
+ proxyBodySize: a['nginx.ingress.kubernetes.io/proxy-body-size'] || a['nginx.org/client-max-body-size'] || null,
1513
+ cors, certManager: cm,
1514
+ backendServices: [...new Set(routes.map((r) => r.service).filter(Boolean))],
1515
+ };
1516
+ }
1517
+
1518
+ // Namespace → { Services (name+selector), parsed Ingresses } for the pod list. The client
1519
+ // matches each pod's labels to a Service's selector, then to the Ingress(es) routing to it
1520
+ // — surfacing host, TLS, class, CORS, redirect and body-size. Namespaced list reads
1521
+ // (Services + Ingresses), allowed here even where External Secrets are not.
1522
+ app.get('/api/aks/:cluster/ingress-map', (req, res) => {
1523
+ send(res, (async () => {
1524
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1525
+ const nsList = [...new Set(String(req.query.namespaces || '').split(',')
1526
+ .map((s) => s.trim()).filter((s) => isK8sName(s)))].slice(0, 200);
1527
+ const tolerant = async (path) => { try { return ((await k8sGet(cred, path)).items) || []; } catch (e) { if (e.forbidden || e.notFound) return []; throw e; } };
1528
+ const results = await pool(nsList, async (ns) => {
1529
+ const [svcs, ings] = await Promise.all([
1530
+ tolerant(`/api/v1/namespaces/${ns}/services`),
1531
+ tolerant(`/apis/networking.k8s.io/v1/namespaces/${ns}/ingresses`),
1532
+ ]);
1533
+ const services = svcs
1534
+ .filter((s) => s.spec && s.spec.selector && Object.keys(s.spec.selector).length)
1535
+ .map((s) => ({ name: s.metadata.name, selector: s.spec.selector }));
1536
+ const ingresses = ings.map(parseIngress).filter((i) => i.backendServices.length || i.hosts.length);
1537
+ return [ns, { services, ingresses }];
1538
+ }, 8);
1539
+ const byNamespace = {};
1540
+ for (const r of results) if (r && r[1] && (r[1].services.length || r[1].ingresses.length)) byNamespace[r[0]] = r[1];
1541
+ return { byNamespace };
1009
1542
  })());
1010
1543
  });
1011
1544
 
@@ -1036,7 +1569,7 @@ async function bridgeExec(client, params) {
1036
1569
  const c = clusterById(await aksList(), clusterName);
1037
1570
  if (!c) throw new Error('Cluster is not in your configured/visible list');
1038
1571
  cred = await aksCredential(c);
1039
- token = await getAksToken();
1572
+ token = await getAksToken(cred.sub);
1040
1573
  } catch (e) {
1041
1574
  tell('\r\n\x1b[31m' + (e.network ? 'Cannot reach the cluster — connect to the VPN.' : (e.message || 'auth failed')) + '\x1b[0m\r\n');
1042
1575
  client.close(); return;