akv-manager 0.0.3 → 0.0.4

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,7 +268,8 @@ 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;
@@ -292,6 +293,65 @@ app.get('/api/vaults', async (req, res) => {
292
293
  }
293
294
  });
294
295
 
296
+ // Find a vault's management-plane coordinates (sub + rg) from the cached inventory.
297
+ async function findVaultMeta(name) {
298
+ const vaults = await listVaultsRaw();
299
+ return vaults.find((v) => (v.name || '').toLowerCase() === name.toLowerCase());
300
+ }
301
+
302
+ // "Default" / governance tags that this tool must never edit or remove — they are
303
+ // applied by Azure Policy / automation and are read-only here. Patterns are matched
304
+ // case-insensitively; a trailing '*' is a prefix wildcard (e.g. "automation.*").
305
+ // Operators can override the whole list with AZBO_PROTECTED_TAGS (comma-separated);
306
+ // set it to an empty string to disable protection entirely.
307
+ const PROTECTED_TAGS = (() => {
308
+ const def = ['env', 'environment', 'cost-center', 'costcenter', 'contact-*', 'owner',
309
+ 'solution', 'location', 'roles', 'managed-by', 'managedby',
310
+ 'expirationdate', 'runninginterval', 'automation.*', 'hidden-*'];
311
+ const raw = process.env.AZBO_PROTECTED_TAGS;
312
+ if (raw === undefined) return def;
313
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
314
+ })();
315
+ function tagMatch(pattern, key) {
316
+ const p = pattern.toLowerCase(), k = String(key).toLowerCase();
317
+ return p.endsWith('*') ? k.startsWith(p.slice(0, -1)) : k === p;
318
+ }
319
+ function isProtectedTag(key) { return PROTECTED_TAGS.some((p) => tagMatch(p, key)); }
320
+
321
+ // Replace a vault's tag set (management plane). The body's `tags` object is the
322
+ // COMPLETE desired set — Key Vault's PATCH overwrites the whole tags collection.
323
+ // Protected/default tags are force-preserved from the current state, so they can be
324
+ // neither edited nor removed regardless of what the client sends.
325
+ // Requires management-plane write (e.g. Contributor / Tag Contributor) on the vault.
326
+ app.put('/api/vaults/:name/tags', (req, res) => {
327
+ const { name } = req.params;
328
+ if (!isValidName(name)) return res.status(400).json({ ok: false, error: 'Invalid vault name' });
329
+ const { tags } = req.body || {};
330
+ if (!tags || typeof tags !== 'object' || Array.isArray(tags))
331
+ return res.status(400).json({ ok: false, error: 'tags must be an object of name → value' });
332
+ const entries = Object.entries(tags);
333
+ if (entries.length > 50) return res.status(400).json({ ok: false, error: 'Azure allows at most 50 tags per resource' });
334
+ for (const [k, v] of entries) {
335
+ if (typeof k !== 'string' || !k.length || k.length > 512) return res.status(400).json({ ok: false, error: `Invalid tag name: ${k}` });
336
+ if (typeof v !== 'string' || v.length > 256) return res.status(400).json({ ok: false, error: `Invalid value for tag "${k}" (max 256 chars)` });
337
+ }
338
+ send(res, (async () => {
339
+ const v = await findVaultMeta(name);
340
+ if (!v) throw new Error('Vault not found in inventory — try Refresh first');
341
+ const current = v.tags || {};
342
+ // Start from the client's desired set, then force every protected tag that exists
343
+ // today back to its original value — this blocks edit AND removal of default tags.
344
+ const finalTags = {};
345
+ for (const [k, val] of entries) { if (!isProtectedTag(k)) finalTags[k] = val; }
346
+ for (const [k, val] of Object.entries(current)) { if (isProtectedTag(k)) finalTags[k] = val; }
347
+ const url = `/subscriptions/${v.subscriptionId}/resourceGroups/${v.resourceGroup}` +
348
+ `/providers/Microsoft.KeyVault/vaults/${name}?api-version=2023-07-01`;
349
+ const out = await armFetch(url, { method: 'PATCH', body: { tags: finalTags } });
350
+ v.tags = (out && out.tags) || finalTags; // reflect immediately in the cached inventory
351
+ return { tags: v.tags };
352
+ })());
353
+ });
354
+
295
355
  // ---- API: expiring secrets & certificates (scans every readable vault) -----
296
356
 
297
357
  /** Run async worker over items with bounded concurrency. */
@@ -534,7 +594,8 @@ const ENV_RULES = (() => {
534
594
  })();
535
595
  const PKG_VERSION = (() => { try { return require('./package.json').version; } catch { return null; } })();
536
596
  app.get('/api/config', (req, res) =>
537
- res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null, version: PKG_VERSION } }));
597
+ res.json({ ok: true, data: { envRules: ENV_RULES, title: process.env.AZBO_TITLE || null,
598
+ version: PKG_VERSION, protectedTags: PROTECTED_TAGS } }));
538
599
 
539
600
  function openBrowser(url) {
540
601
  if (process.env.AZBO_NO_OPEN) return;