|
| 1 | +// Key rotation: the plan per key and the run queue, for the Rotation page, the |
| 2 | +// API and the MCP tools. |
| 3 | +// |
| 4 | +// The server never sees a value (E2E rule). The plan is computed from what it |
| 5 | +// does see: key names, value fingerprints and update times, matched against the |
| 6 | +// same built-in catalog the CLI uses (@logicsrc/cli's rotation-catalog). A key's |
| 7 | +// own `__ROTATE__<KEY>` entry is encrypted, so the page can only say that one |
| 8 | +// exists; the CLI, which can decrypt it, is the authority on what it says. |
| 9 | +import { all } from "../db.mjs"; |
| 10 | +import { planRotations, isoTime } from "@logicsrc/cli/dist/rotation-catalog.js"; |
| 11 | +import { DESKTOP_RECIPES } from "@logicsrc/cli/dist/desktop-recipes.js"; |
| 12 | + |
| 13 | +const ROTATE_PREFIX = "__ROTATE__"; |
| 14 | + |
| 15 | +/** Vault names split the way the CLI does: <project>--<env>. */ |
| 16 | +export function splitVaultName(name) { |
| 17 | + const i = String(name).lastIndexOf("--"); |
| 18 | + return i > 0 ? { project: name.slice(0, i), env: name.slice(i + 2) } : { project: name, env: "" }; |
| 19 | +} |
| 20 | + |
| 21 | +/** |
| 22 | + * The rotation plan for a team: one row per distinct (key, value), like |
| 23 | + * `logicsrc teams rotation`. `vaultFilter(name)` limits it to vaults the caller |
| 24 | + * may see (a machine key's scope). |
| 25 | + */ |
| 26 | +export async function rotationPlan(teamId, { project, env, keys, vaultFilter = () => true } = {}) { |
| 27 | + const rows = (await all( |
| 28 | + `SELECT v.name AS vault, s.name AS key, s.fingerprint, s.updated_at |
| 29 | + FROM credshare_secrets s JOIN credshare_vaults v ON v.id = s.vault_id |
| 30 | + WHERE v.team_id = ?`, |
| 31 | + [teamId] |
| 32 | + )).filter((r) => vaultFilter(r.vault)); |
| 33 | + const entries = new Set(rows.filter((r) => r.key.startsWith(ROTATE_PREFIX)).map((r) => `${r.vault}\u0000${r.key.slice(ROTATE_PREFIX.length)}`)); |
| 34 | + // Every vault:key per fingerprint across the team (a value pasted under other names counts). |
| 35 | + const byFp = new Map(); |
| 36 | + for (const r of rows) { |
| 37 | + if (r.key.startsWith(ROTATE_PREFIX)) continue; |
| 38 | + byFp.set(r.fingerprint, [...(byFp.get(r.fingerprint) ?? []), `${r.vault}:${r.key}`]); |
| 39 | + } |
| 40 | + const inFilter = (vault) => { |
| 41 | + const p = splitVaultName(vault); |
| 42 | + return (!project || p.project === project) && (!env || p.env === env); |
| 43 | + }; |
| 44 | + const wanted = keys?.length ? new Set(keys.map((k) => String(k).toUpperCase())) : null; |
| 45 | + const plan = planRotations( |
| 46 | + rows.filter((r) => inFilter(r.vault)).map((r) => ({ vault: r.vault, key: r.key, fingerprint: r.fingerprint, updatedAt: Number(r.updated_at) })), |
| 47 | + new Map() |
| 48 | + ).filter((p) => !wanted || wanted.has(p.key.toUpperCase())); |
| 49 | + return plan.map((p) => { |
| 50 | + const spec = p.spec ?? {}; |
| 51 | + const recipe = spec.method === "desktop" ? DESKTOP_RECIPES[spec.recipe] : null; |
| 52 | + const everywhere = byFp.get(p.fingerprint) ?? []; |
| 53 | + return { |
| 54 | + key: p.key, |
| 55 | + method: p.method, |
| 56 | + ...(spec.provider ? { provider: spec.provider } : {}), |
| 57 | + ...(recipe ? { recipe: recipe.id, desktopMode: recipe.mode } : {}), |
| 58 | + automatic: p.automatic, |
| 59 | + owner: spec.owner ?? null, |
| 60 | + family: spec.family ?? null, |
| 61 | + url: spec.url ?? null, |
| 62 | + steps: spec.steps ?? [], |
| 63 | + consumers: spec.consumers ?? [], |
| 64 | + disruptive: spec.disruptive ?? null, |
| 65 | + never: Boolean(spec.never), |
| 66 | + note: spec.note ?? null, |
| 67 | + vaults: p.vaults, |
| 68 | + vaultCount: everywhere.length || p.vaults.length, |
| 69 | + alsoIn: everywhere.filter((vk) => !p.vaults.some((v) => vk === `${v}:${p.key}`)), |
| 70 | + lastRotated: isoTime(p.oldestUpdate), |
| 71 | + // The key's own encrypted instructions override the catalog; only the CLI can read them. |
| 72 | + hasEntry: p.vaults.some((v) => entries.has(`${v}\u0000${p.key}`)) |
| 73 | + }; |
| 74 | + }); |
| 75 | +} |
| 76 | + |
| 77 | +// ---- runs ------------------------------------------------------------------------------ |
| 78 | + |
| 79 | +const KEY_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/; |
| 80 | +const HOST = /^[A-Za-z0-9._@-]{1,100}$/; |
| 81 | +const RESULT_STATUSES = new Set(["rotated", "would-rotate", "todo", "failed", "rolled-back", "skipped", "covered", "needs-login", "busy"]); |
| 82 | + |
| 83 | +export class RotationRunError extends Error {} |
| 84 | + |
| 85 | +/** Validate a queue request. Throws RotationRunError with a message for the caller. */ |
| 86 | +export function parseRunRequest(body = {}) { |
| 87 | + const keys = [...new Set((Array.isArray(body.keys) ? body.keys : String(body.keys ?? "").split(/[\s,]+/)).map((k) => String(k).trim()).filter(Boolean))]; |
| 88 | + if (!keys.length) throw new RotationRunError("Pick at least one key to rotate."); |
| 89 | + if (keys.length > 200) throw new RotationRunError("At most 200 keys per run."); |
| 90 | + const bad = keys.find((k) => !KEY_NAME.test(k)); |
| 91 | + if (bad) throw new RotationRunError(`Not a key name: ${bad.slice(0, 60)}`); |
| 92 | + const desktop = body.desktop ? String(body.desktop).trim() : ""; |
| 93 | + if (desktop && !HOST.test(desktop)) throw new RotationRunError("desktop must be an ssh host alias"); |
| 94 | + const name = (v) => (v ? String(v).trim() : "") || null; |
| 95 | + const project = name(body.project); |
| 96 | + const env = name(body.env); |
| 97 | + for (const v of [project, env]) if (v && !/^[a-z0-9][a-z0-9-]{0,62}$/.test(v)) throw new RotationRunError(`Bad project/env name: ${v.slice(0, 60)}`); |
| 98 | + const truthy = (v) => v === true || v === "1" || v === "on" || v === "true"; |
| 99 | + return { |
| 100 | + keys, |
| 101 | + project, |
| 102 | + env, |
| 103 | + approve: truthy(body.approve), |
| 104 | + options: { |
| 105 | + ...(desktop ? { desktop } : {}), |
| 106 | + ...(truthy(body.propagate) ? { propagate: true } : {}), |
| 107 | + ...(truthy(body.allowRoll) ? { allowRoll: true } : {}) |
| 108 | + } |
| 109 | + }; |
| 110 | +} |
| 111 | + |
| 112 | +/** |
| 113 | + * Keep only the fields a runner may report, so a buggy or hostile runner cannot |
| 114 | + * park arbitrary text (or a value) in the database: statuses, names, short notes. |
| 115 | + */ |
| 116 | +export function sanitizeResults(results) { |
| 117 | + if (!Array.isArray(results)) return []; |
| 118 | + const str = (v, n = 300) => (typeof v === "string" ? v.slice(0, n) : undefined); |
| 119 | + const names = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === "string").slice(0, 100).map((x) => x.slice(0, 200)) : undefined); |
| 120 | + return results.slice(0, 500).map((r) => ({ |
| 121 | + key: str(r?.key, 128) ?? "?", |
| 122 | + method: str(r?.method, 64), |
| 123 | + status: RESULT_STATUSES.has(r?.status) ? r.status : "failed", |
| 124 | + vaults: names(r?.vaults), |
| 125 | + written: names(r?.written), |
| 126 | + sites: Array.isArray(r?.sites) ? r.sites.slice(0, 50).map((s) => ({ site: str(s?.site, 120), ok: typeof s?.ok === "boolean" ? s.ok : undefined, health: str(s?.health, 120) })) : undefined, |
| 127 | + note: str(r?.note), |
| 128 | + error: str(r?.error) |
| 129 | + })); |
| 130 | +} |
| 131 | + |
| 132 | +export function runRow(r) { |
| 133 | + const parse = (s, d) => { try { return s ? JSON.parse(s) : d; } catch { return d; } }; |
| 134 | + return { |
| 135 | + id: r.id, |
| 136 | + keys: parse(r.keys, []), |
| 137 | + project: r.project ?? null, |
| 138 | + env: r.env ?? null, |
| 139 | + approve: Boolean(Number(r.approve)), |
| 140 | + options: parse(r.options, {}), |
| 141 | + status: r.status, |
| 142 | + requestedBy: r.requested_by_email ?? r.requested_by, |
| 143 | + claimedBy: r.claimed_by ?? null, |
| 144 | + claimedAt: r.claimed_at ? Number(r.claimed_at) : null, |
| 145 | + finishedAt: r.finished_at ? Number(r.finished_at) : null, |
| 146 | + createdAt: Number(r.created_at), |
| 147 | + summary: r.summary ?? null, |
| 148 | + results: parse(r.results, null) |
| 149 | + }; |
| 150 | +} |
| 151 | + |
| 152 | +/** "3 rotated, 1 todo" from a result list. */ |
| 153 | +export function summarize(results) { |
| 154 | + const counts = {}; |
| 155 | + for (const r of results) counts[r.status] = (counts[r.status] ?? 0) + 1; |
| 156 | + return Object.entries(counts).map(([k, n]) => `${n} ${k}`).join(", ") || "nothing"; |
| 157 | +} |
0 commit comments