akv-manager 0.1.0 → 0.2.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,7 +738,8 @@ 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;
@@ -505,7 +751,7 @@ async function kvWrite(vaultName, method, pathQuery, body) {
505
751
  } catch (e) {
506
752
  throw Object.assign(new Error(e.name === 'AbortError' ? 'Vault request timed out' : e.message), {});
507
753
  } finally { clearTimeout(timer); }
508
- if (resp.status === 401) { tokenCache.delete(VAULT_RES); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
754
+ if (resp.status === 401) { dropToken(VAULT_RES, scope); throw Object.assign(new Error('Token rejected — run az login'), { needLogin: true }); }
509
755
  if (resp.status === 403) throw await forbiddenError(resp);
510
756
  if (resp.status === 404) throw Object.assign(new Error('Vault or item not found'), { notFound: true });
511
757
  if (!resp.ok) {
@@ -608,12 +854,23 @@ app.post('/api/batch', async (req, res) => {
608
854
 
609
855
  const https = require('https');
610
856
  const AKS_AAD_SERVER_APP = '6dae42f8-4368-4678-94ff-3960e28e3630'; // Microsoft public constant
611
- const getAksToken = () => getToken(AKS_AAD_SERVER_APP);
857
+ const getAksToken = (sub) => getToken(AKS_AAD_SERVER_APP, sub);
612
858
  const K8S_NAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/; // DNS-1123-ish (namespaces, pods, cms)
613
859
  const isK8sName = (v) => typeof v === 'string' && K8S_NAME.test(v);
614
860
 
615
- // Operator-supplied cluster list — NEVER baked in. JSON via AZBO_AKS_CLUSTERS, or a
616
- // path via AZBO_AKS_CLUSTERS_FILE. Each: { name, resourceGroup, subscription, env?, label? }.
861
+ // Cluster list — NEVER baked in. Two sources, merged: (1) the operator's file/env
862
+ // (AZBO_AKS_CLUSTERS / AZBO_AKS_CLUSTERS_FILE), and (2) a list the user pastes in the
863
+ // UI, persisted in their browser's localStorage and registered with this local server
864
+ // for the session. Each entry: { name, resourceGroup, subscription, env?, label? }.
865
+ const RG_RE = /^[\w.()-]{1,90}$/;
866
+ function clusterValid(c) {
867
+ return c && isValidName(c.name) && RG_RE.test(c.resourceGroup || '') && SUB_RE.test(c.subscription || '');
868
+ }
869
+ function normalizeClusters(arr, source) {
870
+ return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
871
+ .map((c) => ({ name: String(c.name), resourceGroup: String(c.resourceGroup), subscription: String(c.subscription),
872
+ env: c.env ? String(c.env) : '', label: c.label ? String(c.label) : String(c.name), configured: true, source }));
873
+ }
617
874
  const AKS_CLUSTERS = (() => {
618
875
  let raw = process.env.AZBO_AKS_CLUSTERS;
619
876
  if (!raw && process.env.AZBO_AKS_CLUSTERS_FILE) {
@@ -621,13 +878,10 @@ const AKS_CLUSTERS = (() => {
621
878
  catch (e) { console.warn(' AZBO_AKS_CLUSTERS_FILE could not be read:', e.message); }
622
879
  }
623
880
  if (!raw) return [];
624
- try {
625
- const arr = JSON.parse(raw);
626
- return (Array.isArray(arr) ? arr : []).filter((c) => c && c.name && c.resourceGroup && c.subscription)
627
- .map((c) => ({ name: c.name, resourceGroup: c.resourceGroup, subscription: c.subscription,
628
- env: c.env || '', label: c.label || c.name, configured: true }));
629
- } catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
881
+ try { return normalizeClusters(JSON.parse(raw), 'file'); }
882
+ catch { console.warn(' AZBO_AKS_CLUSTERS is not valid JSON — ignoring.'); return []; }
630
883
  })();
884
+ let SESSION_CLUSTERS = []; // set by the client via POST /api/aks/clusters (from its localStorage)
631
885
 
632
886
  async function resolveSubId(nameOrId) {
633
887
  if (/^[0-9a-fA-F-]{36}$/.test(nameOrId)) return nameOrId;
@@ -646,14 +900,16 @@ async function aksCredential(c) {
646
900
  const url = `/subscriptions/${subId}/resourceGroups/${c.resourceGroup}` +
647
901
  `/providers/Microsoft.ContainerService/managedClusters/${c.name}` +
648
902
  `/listClusterUserCredential?api-version=2024-05-01`;
649
- const out = await armFetch(url, { method: 'POST' });
903
+ const out = await armFetch(url, { method: 'POST', sub: subId });
650
904
  const kc = out.kubeconfigs && out.kubeconfigs[0];
651
905
  if (!kc || !kc.value) throw new Error('No kubeconfig returned for this cluster');
652
906
  const yaml = Buffer.from(kc.value, 'base64').toString('utf8');
653
907
  const server = (yaml.match(/server:\s*(\S+)/) || [])[1];
654
908
  const caB64 = (yaml.match(/certificate-authority-data:\s*(\S+)/) || [])[1];
655
909
  if (!server) throw new Error('Could not parse API server URL from kubeconfig');
656
- const cred = { server: server.replace(/\/+$/, ''),
910
+ // Carry the cluster's subscription on the cred so k8sGet mints the right tenant's
911
+ // AKS token — this is what makes cross-tenant clusters work in "All accounts" mode.
912
+ const cred = { server: server.replace(/\/+$/, ''), sub: subId,
657
913
  ca: caB64 ? Buffer.from(caB64, 'base64').toString('utf8') : undefined, at: Date.now() };
658
914
  aksCredCache.set(key, cred);
659
915
  return cred;
@@ -673,7 +929,7 @@ function imageTag(img) {
673
929
  // One GET against a cluster's Kubernetes API. raw=true returns text (for pod logs).
674
930
  function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
675
931
  return new Promise((resolve, reject) => {
676
- getAksToken().then((token) => {
932
+ getAksToken(cred.sub).then((token) => {
677
933
  const u = new URL(cred.server + pathQuery);
678
934
  const req = https.request({
679
935
  hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method: 'GET',
@@ -685,7 +941,7 @@ function k8sGet(cred, pathQuery, { raw = false, timeout = 25000 } = {}) {
685
941
  res.on('data', (c) => { data += c; });
686
942
  res.on('end', () => {
687
943
  const s = res.statusCode;
688
- if (s === 401) { tokenCache.delete(AKS_AAD_SERVER_APP); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
944
+ if (s === 401) { dropToken(AKS_AAD_SERVER_APP, cred.sub); return reject(Object.assign(new Error('Token rejected — run az login'), { needLogin: true })); }
689
945
  if (s === 403) return reject(Object.assign(new Error(k8sMsg(data) || 'Forbidden'), { forbidden: true }));
690
946
  if (s === 404) return reject(Object.assign(new Error(k8sMsg(data) || 'Not found'), { notFound: true }));
691
947
  if (s >= 400) return reject(new Error(k8sMsg(data) || `Kubernetes returned ${s}`));
@@ -712,23 +968,40 @@ const AKS_QUERY =
712
968
  "| project name, resourceGroup, subscriptionId, " +
713
969
  "ver=tostring(properties.kubernetesVersion), " +
714
970
  "priv=tostring(properties.apiServerAccessProfile.enablePrivateCluster) | order by name asc";
715
- async function discoverClusters() {
716
- try {
717
- const subIds = (await listSubscriptions()).map((s) => s.subscriptionId);
971
+ // Cached so getClusterChecked (called on every pod/log/configmap request) doesn't
972
+ // re-run a Resource Graph query each time. Force-refreshed via ?refresh=1.
973
+ let aksDiscoverCache = { at: 0, data: null };
974
+ const AKS_DISCOVER_TTL = 60 * 1000;
975
+ async function discoverClusters(force) {
976
+ if (!force && aksDiscoverCache.data && Date.now() - aksDiscoverCache.at < AKS_DISCOVER_TTL) return aksDiscoverCache.data;
977
+ const query = async (subIds, sub) => {
718
978
  const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
719
- method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
979
+ method: 'POST', body: { subscriptions: subIds, query: AKS_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } }, sub,
720
980
  });
721
981
  return (out.data || []).map((c) => ({ name: c.name, resourceGroup: c.resourceGroup,
722
982
  subscription: c.subscriptionId, version: c.ver, private: c.priv === 'true', configured: false }));
723
- } catch { return []; }
983
+ };
984
+ try {
985
+ const data = [];
986
+ if (aggregateMode) {
987
+ // one sweep per tenant; skip tenants that fail (e.g. expired) so the rest still list
988
+ for (const t of tenantsOf(await azAccountList(force))) {
989
+ try { data.push(...await query(t.subIds, t.repSub)); } catch { /* skip this tenant */ }
990
+ }
991
+ } else {
992
+ data.push(...await query((await listSubscriptions()).map((s) => s.subscriptionId), selectedSub));
993
+ }
994
+ aksDiscoverCache = { at: Date.now(), data };
995
+ return data;
996
+ } catch { return aksDiscoverCache.data || []; } // on error, reuse last good result
724
997
  }
725
998
 
726
999
  function clusterById(list, id) { return list.find((c) => c.name === id); }
727
- async function aksList() {
728
- const discovered = await discoverClusters();
1000
+ async function aksList(force) {
1001
+ const discovered = await discoverClusters(force);
729
1002
  const byKey = new Map();
730
1003
  for (const c of discovered) byKey.set(c.name.toLowerCase(), c);
731
- for (const c of AKS_CLUSTERS) { // configured entries win + carry their label/env
1004
+ for (const c of [...AKS_CLUSTERS, ...SESSION_CLUSTERS]) { // configured (file then session) win + carry label/env
732
1005
  const prev = byKey.get(c.name.toLowerCase()) || {};
733
1006
  byKey.set(c.name.toLowerCase(), { ...prev, ...c });
734
1007
  }
@@ -736,7 +1009,23 @@ async function aksList() {
736
1009
  }
737
1010
 
738
1011
  app.get('/api/aks/clusters', (req, res) =>
739
- send(res, aksList().then((clusters) => ({ clusters, configured: AKS_CLUSTERS.length }))));
1012
+ send(res, aksList(!!req.query.refresh).then((clusters) => ({ clusters,
1013
+ configured: AKS_CLUSTERS.length + SESSION_CLUSTERS.length, file: AKS_CLUSTERS.length, session: SESSION_CLUSTERS.length }))));
1014
+
1015
+ // Register the user's pasted cluster list for this session (from their localStorage).
1016
+ // Replaces the previous session list; validated; localhost-only like the rest of the API.
1017
+ app.post('/api/aks/clusters', (req, res) => {
1018
+ const { clusters } = req.body || {};
1019
+ if (!Array.isArray(clusters)) return res.status(400).json({ ok: false, error: 'clusters must be a JSON array' });
1020
+ if (clusters.length > 200) return res.status(400).json({ ok: false, error: 'Too many clusters (max 200)' });
1021
+ for (const c of clusters) {
1022
+ if (!c || !c.name || !c.resourceGroup || !c.subscription) return res.status(400).json({ ok: false, error: `Each entry needs name, resourceGroup, subscription (got: ${JSON.stringify(c).slice(0, 60)})` });
1023
+ if (!clusterValid(c)) return res.status(400).json({ ok: false, error: `Invalid cluster entry "${c.name}" — check name / resourceGroup / subscription` });
1024
+ }
1025
+ SESSION_CLUSTERS = normalizeClusters(clusters, 'session');
1026
+ aksCredCache.clear(); // coordinates may have changed
1027
+ res.json({ ok: true, data: { count: SESSION_CLUSTERS.length } });
1028
+ });
740
1029
 
741
1030
  // Resolve a :cluster route param to a known cluster, or throw a typed 400/404.
742
1031
  async function getClusterChecked(name) {
@@ -764,7 +1053,7 @@ app.get('/api/aks/:cluster/pods', (req, res) =>
764
1053
  const labels = p.metadata.labels || {};
765
1054
  const build = imageTag((spec[0] || {}).image) || labels['app.kubernetes.io/version'] || labels.version || labels['tags.datadoghq.com/version'] || '';
766
1055
  return { name: p.metadata.name, namespace: p.metadata.namespace, phase: p.status && p.status.phase, node: p.spec && p.spec.nodeName,
767
- build,
1056
+ build, labels, // labels: used client-side to match Service selectors → Ingress
768
1057
  restarts: cs.reduce((a, x) => a + (x.restartCount || 0), 0),
769
1058
  ready: `${cs.filter((x) => x.ready).length}/${cs.length || spec.length}`,
770
1059
  containers: spec.map((x) => x.name), created: p.metadata.creationTimestamp };
@@ -876,6 +1165,67 @@ async function fetchSpc(cred, ns, name) {
876
1165
  }
877
1166
  return null;
878
1167
  }
1168
+ // ---- External Secrets helpers (shared by the per-pod trace and the cluster map) -----
1169
+ 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; }
1170
+ function akvVaultOf(store) { const a = store && store.spec && store.spec.provider && store.spec.provider.azurekv; return a ? parseVaultHost(a.vaultUrl || a.vaultUri) : null; }
1171
+ // GET the first readable API version among candidates; tolerate not-installed (404) / no-access (403).
1172
+ async function k8sFirst(cred, paths) {
1173
+ for (const p of paths) { try { return await k8sGet(cred, p); } catch (e) { if (e.notFound || e.forbidden) continue; throw e; } }
1174
+ return null;
1175
+ }
1176
+ const ES_GROUP = 'external-secrets.io', CSI_GROUP = 'secrets-store.csi.x-k8s.io';
1177
+ const esPaths = (suffix) => [`/apis/${ES_GROUP}/v1/${suffix}`, `/apis/${ES_GROUP}/v1beta1/${suffix}`];
1178
+ const csiPaths = (suffix) => [`/apis/${CSI_GROUP}/v1/${suffix}`, `/apis/${CSI_GROUP}/v1alpha1/${suffix}`];
1179
+ // Resolve a secretStoreRef ({kind,name}) to its azurekv vault short-name (or null).
1180
+ async function storeRefVault(cred, ns, ref) {
1181
+ if (!ref || !ref.name) return null;
1182
+ const store = await k8sFirst(cred, ref.kind === 'ClusterSecretStore'
1183
+ ? esPaths(`clustersecretstores/${ref.name}`) : esPaths(`namespaces/${ns}/secretstores/${ref.name}`));
1184
+ return akvVaultOf(store);
1185
+ }
1186
+ // When listing SecretStores is denied but get-by-name isn't (common on locked-down
1187
+ // clusters), try these conventional store names. Override/extend for your naming via
1188
+ // AZBO_SECRETSTORE_NAMES (comma-separated).
1189
+ const CONVENTIONAL_STORES = (process.env.AZBO_SECRETSTORE_NAMES || 'secretstore-app,secretstore,azure-store,azurekv,azure-kv')
1190
+ .split(',').map((s) => s.trim()).filter(Boolean);
1191
+ // Vault short-names a namespace reads from, resolved directly from its External Secrets
1192
+ // config (SecretStores + ExternalSecrets) and CSI SecretProviderClasses. Mirrors
1193
+ // `kubectl get secretstore -n <ns>`, so it needs only namespaced read. Reports
1194
+ // `restricted` when RBAC blocks reading those CRDs (so the UI can say so, not "none").
1195
+ // With `deep`, also GETs conventional SecretStore names — works with get-only RBAC.
1196
+ async function namespaceVaults(cred, ns, { deep = false } = {}) {
1197
+ const set = new Set();
1198
+ let forbidden = false;
1199
+ const listItems = async (paths) => {
1200
+ for (const p of paths) {
1201
+ try { return (await k8sGet(cred, p)).items || []; }
1202
+ catch (e) { if (e.forbidden) { forbidden = true; return null; } if (e.notFound) continue; throw e; }
1203
+ }
1204
+ return [];
1205
+ };
1206
+ for (const it of (await listItems(esPaths(`namespaces/${ns}/secretstores`)) || [])) { const v = akvVaultOf(it); if (v) set.add(v); }
1207
+ for (const it of (await listItems(esPaths(`namespaces/${ns}/externalsecrets`)) || [])) {
1208
+ const v = await storeRefVault(cred, ns, (it.spec && it.spec.secretStoreRef) || {}); if (v) set.add(v);
1209
+ }
1210
+ for (const it of (await listItems(csiPaths(`namespaces/${ns}/secretproviderclasses`)) || [])) {
1211
+ const params = (it.spec && it.spec.parameters) || {};
1212
+ const kv = params.keyvaultName || params.keyvaultname || params.keyVaultName;
1213
+ if (kv) set.add(String(kv).toLowerCase());
1214
+ }
1215
+ // get-only RBAC fallback: fetch conventional SecretStore names directly by name.
1216
+ if (deep && !set.size) {
1217
+ for (const nm of CONVENTIONAL_STORES) {
1218
+ let store = null, stop = false;
1219
+ for (const p of esPaths(`namespaces/${ns}/secretstores/${nm}`)) {
1220
+ try { store = await k8sGet(cred, p); break; }
1221
+ catch (e) { if (e.forbidden) { forbidden = true; stop = true; break; } if (e.notFound) continue; throw e; }
1222
+ }
1223
+ if (stop) break;
1224
+ const v = akvVaultOf(store); if (v) set.add(v);
1225
+ }
1226
+ }
1227
+ return { vaults: [...set], restricted: forbidden && set.size === 0 };
1228
+ }
879
1229
 
880
1230
  app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
881
1231
  const { ns, pod } = req.params;
@@ -980,8 +1330,100 @@ app.get('/api/aks/:cluster/:ns/pods/:pod/keyvaults', (req, res) => {
980
1330
  } catch (e) { entry.reason = e.forbidden ? 'needs read on externalsecrets / secretstores' : (e.message || 'unreadable'); }
981
1331
  externalSecrets.push(entry);
982
1332
  }
1333
+ // 5) Fallback that mirrors `kubectl get secretstore -n <ns>`: resolve the namespace's
1334
+ // own External Secrets config directly. Robust when the per-secret owner chain above
1335
+ // doesn't resolve (e.g. a frontend pod that never mounts the synced Secret).
1336
+ let nsInfo = { vaults: [], restricted: false };
1337
+ try { nsInfo = await namespaceVaults(cred, ns, { deep: true }); } catch { /* namespace CRDs not readable */ }
1338
+ for (const v of nsInfo.vaults) { set.add(v); (srcMap[v] = srcMap[v] || new Set()).add('namespace secretstore'); }
1339
+
983
1340
  const vaults = [...set].sort().map((name) => ({ name, sources: [...(srcMap[name] || [])] }));
984
- return { vaults, configmapsScanned: scanned, spcs, externalSecrets };
1341
+ return { vaults, configmapsScanned: scanned, spcs, externalSecrets, namespaceVaults: nsInfo.vaults, restricted: nsInfo.restricted };
1342
+ })());
1343
+ });
1344
+
1345
+ // Namespace → Key Vault map for the pod list. Resolves each namespace directly from its
1346
+ // own External Secrets config + CSI classes (namespaced reads — so it works with
1347
+ // per-namespace RBAC, unlike a cluster-wide list). The client passes ?namespaces= (the
1348
+ // namespaces its pods are in) so we only probe what's on screen.
1349
+ app.get('/api/aks/:cluster/secret-vaults', (req, res) => {
1350
+ send(res, (async () => {
1351
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1352
+ const nsList = [...new Set(String(req.query.namespaces || '').split(',')
1353
+ .map((s) => s.trim()).filter((s) => isK8sName(s)))].slice(0, 200); // bound the fan-out
1354
+ const byNamespace = {}, restricted = {};
1355
+ const results = await pool(nsList, async (ns) => [ns, await namespaceVaults(cred, ns)], 8);
1356
+ for (const r of results) {
1357
+ if (!r) continue;
1358
+ const [ns, info] = r;
1359
+ if (info.vaults.length) byNamespace[ns] = info.vaults.sort();
1360
+ else if (info.restricted) restricted[ns] = true;
1361
+ }
1362
+ return { byNamespace, restricted };
1363
+ })());
1364
+ });
1365
+
1366
+ const ingBackendSvc = (b) => (b && ((b.service && b.service.name) || b.serviceName)) || null;
1367
+ const ingBackendPort = (b) => { const s = b && b.service; const p = s && s.port; return (p && (p.name || p.number)) || (b && b.servicePort) || null; };
1368
+ // Parse an Ingress into the config worth showing: routes (host/path/backend:port), TLS
1369
+ // (secret + cert-manager/ACME), class, and the common nginx annotations (HTTPS redirect,
1370
+ // CORS, body size). Keeps only a curated set so the map stays lean.
1371
+ function parseIngress(ing) {
1372
+ const a = (ing.metadata && ing.metadata.annotations) || {};
1373
+ const spec = ing.spec || {};
1374
+ const rules = spec.rules || [];
1375
+ const routes = [];
1376
+ for (const r of rules) for (const p of ((r.http && r.http.paths) || []))
1377
+ routes.push({ host: r.host || null, path: p.path || '/', pathType: p.pathType || null, service: ingBackendSvc(p.backend), port: ingBackendPort(p.backend) });
1378
+ 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']) ? {
1379
+ commonName: a['cert-manager.io/common-name'] || null, tlsAcme: a['kubernetes.io/tls-acme'] || null,
1380
+ issuer: a['cert-manager.io/cluster-issuer'] || a['cert-manager.io/issuer'] || null,
1381
+ challenge: a['certmanager.k8s.io/acme-challenge-type'] || a['cert-manager.io/acme-challenge-type'] || null,
1382
+ dnsProvider: a['certmanager.k8s.io/acme-dns01-provider'] || a['cert-manager.io/acme-dns01-provider'] || null,
1383
+ } : null;
1384
+ const cors = a['nginx.ingress.kubernetes.io/enable-cors'] === 'true' ? {
1385
+ origin: a['nginx.ingress.kubernetes.io/cors-allow-origin'] || null,
1386
+ methods: a['nginx.ingress.kubernetes.io/cors-allow-methods'] || null,
1387
+ headers: a['nginx.ingress.kubernetes.io/cors-allow-headers'] || null,
1388
+ } : null;
1389
+ return {
1390
+ name: ing.metadata && ing.metadata.name,
1391
+ className: spec.ingressClassName || a['kubernetes.io/ingress.class'] || null,
1392
+ hosts: [...new Set(rules.map((r) => r.host).filter(Boolean))],
1393
+ routes,
1394
+ tls: (spec.tls || []).map((t) => ({ hosts: t.hosts || [], secretName: t.secretName || null })),
1395
+ sslRedirect: a['nginx.ingress.kubernetes.io/force-ssl-redirect'] === 'true' || a['nginx.org/redirect-to-https'] === 'true',
1396
+ allowHttp: a['kubernetes.io/ingress.allow-http'] || null,
1397
+ proxyBodySize: a['nginx.ingress.kubernetes.io/proxy-body-size'] || a['nginx.org/client-max-body-size'] || null,
1398
+ cors, certManager: cm,
1399
+ backendServices: [...new Set(routes.map((r) => r.service).filter(Boolean))],
1400
+ };
1401
+ }
1402
+
1403
+ // Namespace → { Services (name+selector), parsed Ingresses } for the pod list. The client
1404
+ // matches each pod's labels to a Service's selector, then to the Ingress(es) routing to it
1405
+ // — surfacing host, TLS, class, CORS, redirect and body-size. Namespaced list reads
1406
+ // (Services + Ingresses), allowed here even where External Secrets are not.
1407
+ app.get('/api/aks/:cluster/ingress-map', (req, res) => {
1408
+ send(res, (async () => {
1409
+ const cred = await aksCredential(await getClusterChecked(req.params.cluster));
1410
+ const nsList = [...new Set(String(req.query.namespaces || '').split(',')
1411
+ .map((s) => s.trim()).filter((s) => isK8sName(s)))].slice(0, 200);
1412
+ const tolerant = async (path) => { try { return ((await k8sGet(cred, path)).items) || []; } catch (e) { if (e.forbidden || e.notFound) return []; throw e; } };
1413
+ const results = await pool(nsList, async (ns) => {
1414
+ const [svcs, ings] = await Promise.all([
1415
+ tolerant(`/api/v1/namespaces/${ns}/services`),
1416
+ tolerant(`/apis/networking.k8s.io/v1/namespaces/${ns}/ingresses`),
1417
+ ]);
1418
+ const services = svcs
1419
+ .filter((s) => s.spec && s.spec.selector && Object.keys(s.spec.selector).length)
1420
+ .map((s) => ({ name: s.metadata.name, selector: s.spec.selector }));
1421
+ const ingresses = ings.map(parseIngress).filter((i) => i.backendServices.length || i.hosts.length);
1422
+ return [ns, { services, ingresses }];
1423
+ }, 8);
1424
+ const byNamespace = {};
1425
+ for (const r of results) if (r && r[1] && (r[1].services.length || r[1].ingresses.length)) byNamespace[r[0]] = r[1];
1426
+ return { byNamespace };
985
1427
  })());
986
1428
  });
987
1429
 
@@ -1012,7 +1454,7 @@ async function bridgeExec(client, params) {
1012
1454
  const c = clusterById(await aksList(), clusterName);
1013
1455
  if (!c) throw new Error('Cluster is not in your configured/visible list');
1014
1456
  cred = await aksCredential(c);
1015
- token = await getAksToken();
1457
+ token = await getAksToken(cred.sub);
1016
1458
  } catch (e) {
1017
1459
  tell('\r\n\x1b[31m' + (e.network ? 'Cannot reach the cluster — connect to the VPN.' : (e.message || 'auth failed')) + '\x1b[0m\r\n');
1018
1460
  client.close(); return;