akv-manager 0.0.3 → 0.0.5

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/public/styles.css CHANGED
@@ -240,10 +240,130 @@
240
240
  border-radius:999px;margin:16px auto 0;overflow:hidden}
241
241
  .progress .bar{height:100%;background:linear-gradient(90deg,var(--azure),var(--ok));border-radius:999px;transition:width .4s}
242
242
 
243
+ /* ---------- sidebar: pinned / recent + account switcher ---------- */
244
+ .navpins{display:flex;flex-direction:column;gap:1px;margin:2px 0 4px}
245
+ .navpin{display:flex;align-items:center;gap:8px;width:100%;text-align:left;background:transparent;border:0;
246
+ color:var(--muted);padding:6px 11px;border-radius:var(--r-sm);cursor:pointer;font-family:var(--mono);
247
+ font-size:11.5px;transition:.12s;overflow:hidden}
248
+ .navpin:hover{background:rgba(255,255,255,.03);color:var(--text)}
249
+ .navpin.active{background:var(--azuredim);color:#fff}
250
+ .navpin .pn{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
251
+ .navpin .star{color:var(--warn);font-size:10px;flex:none}
252
+ .navpin .env-dot{width:6px;height:6px;border-radius:50%;flex:none}
253
+ .navempty{font-family:var(--mono);font-size:10.5px;color:var(--faint);padding:4px 12px}
254
+ .acct{margin-top:auto;border-top:1px solid var(--line2);padding-top:10px}
255
+ .acctbtn{display:flex;align-items:center;gap:9px;width:100%;text-align:left;background:transparent;border:0;
256
+ color:var(--muted);padding:8px;border-radius:var(--r-sm);cursor:pointer;font-size:12px;transition:.12s;line-height:1.5}
257
+ .acctbtn:hover{background:rgba(255,255,255,.03)}
258
+ .acctbtn b{color:var(--text);font-weight:600}
259
+ .acctbtn .chev{margin-left:auto;color:var(--faint);flex:none}
260
+
261
+ /* ---------- command palette (⌘K) ---------- */
262
+ .cmdk-ov{position:fixed;inset:0;background:rgba(5,8,14,.55);backdrop-filter:blur(3px);opacity:0;
263
+ pointer-events:none;transition:.14s;z-index:70;display:flex;align-items:flex-start;justify-content:center;padding-top:12vh}
264
+ .cmdk-ov.show{opacity:1;pointer-events:auto}
265
+ .cmdk{width:min(640px,92vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;
266
+ box-shadow:0 40px 90px -30px rgba(0,0,0,.85);overflow:hidden;transform:translateY(-8px);transition:.16s}
267
+ .cmdk-ov.show .cmdk{transform:translateY(0)}
268
+ .cmdk-in{display:flex;align-items:center;gap:11px;padding:15px 18px;border-bottom:1px solid var(--line)}
269
+ .cmdk-in svg{width:17px;height:17px;color:var(--faint);flex:none}
270
+ .cmdk-in input{flex:1;background:transparent;border:0;outline:0;color:var(--text);font-family:var(--sans);font-size:15px}
271
+ .cmdk-in input::placeholder{color:var(--faint)}
272
+ .cmdk-in kbd{font-family:var(--mono);font-size:10px;color:var(--faint);border:1px solid var(--line);border-radius:5px;padding:2px 6px}
273
+ .cmdk-list{max-height:54vh;overflow:auto;padding:7px}
274
+ .cmdk-sec{font-family:var(--mono);font-size:9.5px;letter-spacing:.16em;text-transform:uppercase;color:var(--faint);padding:9px 11px 5px}
275
+ .cmdk-it{display:flex;align-items:center;gap:11px;padding:9px 11px;border-radius:8px;cursor:pointer}
276
+ .cmdk-it .ico{width:16px;height:16px;color:var(--muted);flex:none}
277
+ .cmdk-it .lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13.5px}
278
+ .cmdk-it .lbl small{color:var(--faint);font-family:var(--mono);font-size:11px;margin-left:8px}
279
+ .cmdk-it .hint{font-family:var(--mono);font-size:10px;color:var(--faint)}
280
+ .cmdk-it.cur{background:var(--azuredim)}
281
+ .cmdk-it.cur .ico{color:var(--azure)}
282
+ .cmdk-empty{padding:26px;text-align:center;color:var(--muted);font-size:13px}
283
+
284
+ /* ---------- dashboard ---------- */
285
+ .actcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:13px;margin-bottom:26px}
286
+ .actcard{display:block;text-align:left;background:linear-gradient(180deg,var(--panel),var(--panel2));
287
+ border:1px solid var(--line);border-radius:var(--r);padding:16px 17px;cursor:pointer;position:relative;
288
+ overflow:hidden;transition:.15s;font-family:inherit}
289
+ .actcard::after{content:"";position:absolute;inset:0 0 auto;height:2px;background:var(--line)}
290
+ .actcard:hover{border-color:var(--azure);transform:translateY(-1px)}
291
+ .actcard .k{display:flex;align-items:center;gap:8px;font-family:var(--mono);color:var(--muted);font-size:10.5px;
292
+ text-transform:uppercase;letter-spacing:.12em}
293
+ .actcard .k svg{width:14px;height:14px}
294
+ .actcard .v{font-family:var(--mono);font-size:32px;font-weight:600;margin-top:11px;line-height:1;letter-spacing:-.02em}
295
+ .actcard .sub{font-family:var(--sans);font-size:12px;color:var(--muted);margin-top:6px}
296
+ .actcard.sev-alert::after{background:var(--alert)} .actcard.sev-alert .v{color:#FF8893}
297
+ .actcard.sev-warn::after{background:var(--warn)} .actcard.sev-warn .v{color:#FBC56B}
298
+ .actcard.sev-azure::after{background:var(--azure)} .actcard.sev-azure .v{color:#8FBEFF}
299
+ .actcard.sev-ok::after{background:var(--ok)} .actcard.sev-ok .v{color:#7FE0C8}
300
+ .dashgrid{display:grid;grid-template-columns:1fr 1fr;gap:22px;align-items:start}
301
+ @media (max-width:1100px){.dashgrid{grid-template-columns:1fr}}
302
+ .secthead2{display:flex;align-items:center;justify-content:space-between;margin:0 0 12px}
303
+ .secthead2 .eyebrow{font-size:11px}
304
+ .vtiles{display:flex;flex-direction:column;gap:7px}
305
+ .vtile{display:flex;align-items:center;gap:11px;background:var(--panel);border:1px solid var(--line);
306
+ border-radius:10px;padding:11px 13px;cursor:pointer;transition:.13s}
307
+ .vtile:hover{border-color:var(--azure);background:var(--raise)}
308
+ .vtile .star{color:var(--warn);font-size:12px;flex:none;background:none;border:0;cursor:pointer;padding:0}
309
+ .vtile .star.off{color:var(--faint)}
310
+ .vtile .nm{flex:1;font-family:var(--mono);font-size:12.5px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
311
+ .vtile .rg{font-family:var(--mono);font-size:11px;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
312
+
313
+ /* ---------- facets (tag filtering) ---------- */
314
+ .facets{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
315
+ .facet{display:flex;align-items:center;gap:6px}
316
+ .facet label{font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
317
+ select.facetsel{font-family:var(--mono);font-size:12px;background:var(--panel2);border:1px solid var(--line);
318
+ color:var(--text);border-radius:7px;padding:6px 9px;cursor:pointer;color-scheme:dark;max-width:200px}
319
+ select.facetsel:focus{outline:none;border-color:var(--azure)}
320
+ select.facetsel.on{border-color:var(--azure);color:#fff}
321
+ .facetclear{font-family:var(--mono);font-size:11px;color:var(--azure);background:none;border:0;cursor:pointer}
322
+
323
+ /* ---------- tag chips + editor ---------- */
324
+ .tagchips{display:flex;gap:5px;flex-wrap:wrap}
325
+ .tagchip{font-family:var(--mono);font-size:10px;color:var(--muted);background:rgba(255,255,255,.04);
326
+ border:1px solid var(--line);border-radius:5px;padding:1px 7px;white-space:nowrap}
327
+ .tagchip b{color:var(--text);font-weight:500}
328
+ .tagedit{display:flex;flex-direction:column;gap:9px;max-width:680px}
329
+ .tagrow{display:flex;align-items:center;gap:9px}
330
+ .tagrow input{font-family:var(--mono);font-size:12px;background:var(--ink);border:1px solid var(--line);
331
+ color:var(--text);border-radius:6px;padding:8px 10px;color-scheme:dark}
332
+ .tagrow .tk{width:230px;flex:none} .tagrow .tv{flex:1}
333
+ .tagrow .del{flex:none}
334
+ .tagrow.fixed input{opacity:.6}
335
+
336
+ /* ---------- routed vault page ---------- */
337
+ .vhead{margin-bottom:18px}
338
+ .crumb{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:6px}
339
+ .crumb a{font-family:var(--mono);font-size:12px;color:var(--muted)}
340
+ .crumb a:hover{color:var(--text)}
341
+ .crumb .sep{color:var(--faint)}
342
+ .crumb .here{font-family:var(--mono);font-size:16px;font-weight:600;color:var(--text);word-break:break-all}
343
+ .star{background:none;border:0;cursor:pointer;color:var(--faint);font-size:16px;padding:0 2px;line-height:1}
344
+ .star.on{color:var(--warn)}
345
+ .vmeta{display:flex;align-items:center;gap:9px;flex-wrap:wrap;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
346
+ .vmeta .mono{word-break:break-all}
347
+ .tabs{display:flex;gap:2px;border-bottom:1px solid var(--line);margin-bottom:16px}
348
+ .tabs button{font-family:var(--sans);font-size:13px;font-weight:500;background:transparent;border:0;
349
+ color:var(--muted);padding:10px 15px;cursor:pointer;position:relative;transition:.12s}
350
+ .tabs button:hover{color:var(--text)}
351
+ .tabs button.active{color:#fff}
352
+ .tabs button.active::after{content:"";position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--azure)}
353
+ .tabs button .c{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-left:6px}
354
+ .vtools{display:flex;flex-direction:column;gap:10px;margin-bottom:6px}
355
+ .vtools-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
356
+ .vtools-row .search{flex:1;min-width:0}
357
+ .vtools-actions{margin-left:auto;display:flex;gap:7px}
358
+ .stick{position:sticky;top:0;z-index:5}
359
+ .itemwrap{margin-top:8px}
360
+
243
361
  @media (max-width:900px){
244
362
  /* .app grid + main padding handled by Tailwind max-[900px]: utilities */
245
363
  aside{position:static;height:auto;flex-direction:row;flex-wrap:wrap;align-items:center}
246
364
  aside nav{display:flex;gap:4px;flex-wrap:wrap} .navlabel{display:none} .who{display:none}
365
+ .navpins,.acct{display:none}
247
366
  input.search{min-width:180px}
367
+ .dashgrid{grid-template-columns:1fr}
248
368
  }
249
369
  @media (prefers-reduced-motion:reduce){*{animation-duration:.01ms!important;transition:none!important}}
package/server.js CHANGED
@@ -268,16 +268,26 @@ const VAULT_QUERY =
268
268
  "| project name, resourceGroup, subscriptionId, location, " +
269
269
  "sku=tostring(properties.sku.name), " +
270
270
  "enableRbac=tostring(properties.enableRbacAuthorization), " +
271
- "uri=tostring(properties.vaultUri) | order by name asc";
271
+ "uri=tostring(properties.vaultUri), tags " +
272
+ "| order by name asc";
272
273
 
273
274
  async function listVaultsRaw(force) {
274
275
  if (!force && vaultCache.data && Date.now() - vaultCache.at < VAULT_TTL) return vaultCache.data;
275
276
  const subIds = (await listSubscriptions(force)).map((s) => s.subscriptionId);
276
- const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
277
- method: 'POST',
278
- body: { subscriptions: subIds, query: VAULT_QUERY, options: { resultFormat: 'objectArray', $top: 1000 } },
279
- });
280
- const data = out.data || [];
277
+ // Resource Graph returns at most 1000 rows per page; follow $skipToken to get them all.
278
+ const data = [];
279
+ let skipToken;
280
+ for (let page = 0; page < 100; page++) { // hard stop at 100k vaults — a safety bound
281
+ const options = { resultFormat: 'objectArray', $top: 1000 };
282
+ if (skipToken) options.$skipToken = skipToken;
283
+ const out = await armFetch('/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01', {
284
+ method: 'POST',
285
+ body: { subscriptions: subIds, query: VAULT_QUERY, options },
286
+ });
287
+ if (out.data) data.push(...out.data);
288
+ skipToken = out.$skipToken;
289
+ if (!skipToken) break;
290
+ }
281
291
  vaultCache = { at: Date.now(), data };
282
292
  return data;
283
293
  }
@@ -292,6 +302,65 @@ app.get('/api/vaults', async (req, res) => {
292
302
  }
293
303
  });
294
304
 
305
+ // Find a vault's management-plane coordinates (sub + rg) from the cached inventory.
306
+ async function findVaultMeta(name) {
307
+ const vaults = await listVaultsRaw();
308
+ return vaults.find((v) => (v.name || '').toLowerCase() === name.toLowerCase());
309
+ }
310
+
311
+ // "Default" / governance tags that this tool must never edit or remove — they are
312
+ // applied by Azure Policy / automation and are read-only here. Patterns are matched
313
+ // case-insensitively; a trailing '*' is a prefix wildcard (e.g. "automation.*").
314
+ // Operators can override the whole list with AZBO_PROTECTED_TAGS (comma-separated);
315
+ // set it to an empty string to disable protection entirely.
316
+ const PROTECTED_TAGS = (() => {
317
+ const def = ['env', 'environment', 'cost-center', 'costcenter', 'contact-*', 'owner',
318
+ 'solution', 'location', 'roles', 'managed-by', 'managedby',
319
+ 'expirationdate', 'runninginterval', 'automation.*', 'hidden-*'];
320
+ const raw = process.env.AZBO_PROTECTED_TAGS;
321
+ if (raw === undefined) return def;
322
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
323
+ })();
324
+ function tagMatch(pattern, key) {
325
+ const p = pattern.toLowerCase(), k = String(key).toLowerCase();
326
+ return p.endsWith('*') ? k.startsWith(p.slice(0, -1)) : k === p;
327
+ }
328
+ function isProtectedTag(key) { return PROTECTED_TAGS.some((p) => tagMatch(p, key)); }
329
+
330
+ // Replace a vault's tag set (management plane). The body's `tags` object is the
331
+ // COMPLETE desired set — Key Vault's PATCH overwrites the whole tags collection.
332
+ // Protected/default tags are force-preserved from the current state, so they can be
333
+ // neither edited nor removed regardless of what the client sends.
334
+ // Requires management-plane write (e.g. Contributor / Tag Contributor) on the vault.
335
+ app.put('/api/vaults/:name/tags', (req, res) => {
336
+ const { name } = req.params;
337
+ if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
338
+ const { tags } = req.body || {};
339
+ if (!tags || typeof tags !== 'object' || Array.isArray(tags))
340
+ return res.status(400).json({ ok: false, error: 'tags must be an object of name → value' });
341
+ const entries = Object.entries(tags);
342
+ if (entries.length > 50) return res.status(400).json({ ok: false, error: 'Azure allows at most 50 tags per resource' });
343
+ for (const [k, v] of entries) {
344
+ if (typeof k !== 'string' || !k.length || k.length > 512) return res.status(400).json({ ok: false, error: `Invalid tag name: ${k}` });
345
+ if (typeof v !== 'string' || v.length > 256) return res.status(400).json({ ok: false, error: `Invalid value for tag "${k}" (max 256 chars)` });
346
+ }
347
+ send(res, (async () => {
348
+ const v = await findVaultMeta(name);
349
+ if (!v) throw new Error('Vault not found in inventory — try Refresh first');
350
+ const current = v.tags || {};
351
+ // Start from the client's desired set, then force every protected tag that exists
352
+ // today back to its original value — this blocks edit AND removal of default tags.
353
+ const finalTags = {};
354
+ for (const [k, val] of entries) { if (!isProtectedTag(k)) finalTags[k] = val; }
355
+ for (const [k, val] of Object.entries(current)) { if (isProtectedTag(k)) finalTags[k] = val; }
356
+ const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
357
+ `/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
358
+ const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags } });
359
+ v.tags = (out && out.tags) || finalTags; // reflect immediately in the cached inventory
360
+ return { tags: v.tags };
361
+ })());
362
+ });
363
+
295
364
  // ---- API: expiring secrets & certificates (scans every readable vault) -----
296
365
 
297
366
  /** Run async worker over items with bounded concurrency. */
@@ -475,6 +544,15 @@ app.patch('/api/vaults/:name/certificates/:cert', (req, res) => {
475
544
  send(res, kvWrite(name, 'PATCH', `/certificates/${cert}?api-version=${KV_API}`, { attributes: attrs }));
476
545
  });
477
546
 
547
+ // Turn a write failure into a precise, actionable reason. A 403 from Key Vault is
548
+ // either a network/firewall block (fix: VPN) or a real data-plane permission gap
549
+ // (fix: grant Set on secrets) — never conflate the two as a bare "forbidden".
550
+ function writeReason(e) {
551
+ if (e.firewall) return 'blocked by the vault firewall — your IP is not allowed (connect to the corporate VPN)';
552
+ if (e.forbidden) return 'no write permission — your identity can read but not Set secrets on this vault';
553
+ return e.message || 'failed';
554
+ }
555
+
478
556
  // Create one or many secrets in a vault. items: [{name, value}], optional common expires.
479
557
  app.post('/api/vaults/:name/secrets-create', async (req, res) => {
480
558
  const { name } = req.params;
@@ -492,7 +570,7 @@ app.post('/api/vaults/:name/secrets-create', async (req, res) => {
492
570
  if (Object.keys(attrs).length) body.attributes = attrs;
493
571
  await kvWrite(name, 'PUT', `/secrets/${it.name}?api-version=${KV_API}`, body);
494
572
  return { ...r, ok: true };
495
- } catch (e) { return { ...r, ok: false, error: e.forbidden ? 'forbidden' : (e.message || 'failed') }; }
573
+ } catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
496
574
  }, 10);
497
575
  res.json({ ok: true, results });
498
576
  });
@@ -517,7 +595,7 @@ app.post('/api/batch', async (req, res) => {
517
595
  await kvWrite(it.vault, 'PATCH', `/secrets/${it.name}?api-version=${KV_API}`, { attributes: attrs });
518
596
  }
519
597
  return { ...r, ok: true };
520
- } catch (e) { return { ...r, ok: false, error: e.forbidden ? 'forbidden' : (e.message || 'failed') }; }
598
+ } catch (e) { return { ...r, ok: false, error: writeReason(e), firewall: !!e.firewall, forbidden: !!e.forbidden }; }
521
599
  }, 12);
522
600
  res.json({ ok: true, results });
523
601
  });
@@ -534,7 +612,8 @@ const ENV_RULES = (() => {
534
612
  })();
535
613
  const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
536
614
  app.get('/api/config', (req, res) =>
537
- res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null, version: PKG_VERSION } }));
615
+ res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
616
+ version: PKG_VERSION, protectedTags: PROTECTED_TAGS } }));
538
617
 
539
618
  function openBrowser(url) {
540
619
  if (process.env.AZBO_NO_OPEN) return;