Cartographie complète (liste_taches/coherence_taches), briefs tacheN + gates validation_tacheN, design tâche 2 (docs/design/tache2/), specs/plans jalon 1-2 et tâche 1.9/2 (Phase 1, Phase 2, SJ-0→3). Validations consignées (1.9 ✅, 2-8 🟡). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Tâche 1.9 — Phase 2 (sécurité credentials) — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development ou superpowers:executing-plans. Étapes en checkbox.
Goal: Isoler les secrets SSH dans une table dédiée machine_credentials (+ table machine_host_keys), de façon non destructive : nouvelle table, écriture dédiée, lecture prioritaire avec fallback sur machines.enc_password, et backfill des machines existantes.
Architecture: Ajout additif (Drizzle/SQLite, migration 0003). machines.enc_password/enc_sudo_password sont CONSERVÉS (non droppés) comme fallback/legacy. Un service credentials écrit/lit machine_credentials ; createMachine y insère, getCreds lit machine_credentials puis retombe sur les colonnes machines si absent ; un backfill (idempotent) crée les lignes manquantes au démarrage. machine_host_keys est créée (schéma) pour la future vérification host key (pas de logique de vérif en Phase 2).
Tech Stack: Drizzle ORM, better-sqlite3, drizzle-kit, vitest.
Invariants
- Non destructif : ne pas dropper
machines.enc_password/enc_sudo_password(NOT NULL conservé). Pas de perte des machines réelles existantes. - Secrets uniquement chiffrés (AES-256-GCM existant,
server/crypto/secrets.ts).machine_credentialsn'est JAMAIS exposée via l'API publique (laMachineViewreste sans secret). - Rétro-compatibilité : une machine sans ligne
machine_credentialsreste utilisable (fallback). Le backfill comble le manque. - Ne pas committer (l'utilisateur gère les commits). Étapes « commit » remplacées par vérification.
- Tree partagé avec du WIP concurrent : ne toucher QUE
server/db/schema.ts, migrations,server/services/credentials.ts(+test),server/services/machines.ts, et le point de backfill (server/db/migrate.tsouserver/index.ts). Relire chaque fichier avant édition (drift possible).
File Structure
server/db/schema.ts # MODIF : +machine_credentials, +machine_host_keys
server/db/migrations/0003_*.sql # généré
server/services/credentials.ts # NOUVEAU : writeCredentials/readCreds/backfill
server/services/credentials.test.ts # NOUVEAU
server/services/machines.ts # MODIF : createMachine écrit credentials ; getCreds lit credentials+fallback
server/db/migrate.ts # MODIF : appeler backfill après migrate
Task 1 : Tables machine_credentials + machine_host_keys
Files: Modify server/db/schema.ts ; generate migration ; extend server/db/schema.test.ts.
- Step 1 : Relire le schéma réel
Run: rtk read server/db/schema.ts (capter l'état courant, préserver tout l'existant : machines/snapshots/executions/apiClients + les 7 tables Phase 1).
- Step 2 : Ajouter les deux tables (à la fin de
schema.ts, avant ou aprèsapiClients, sans rien supprimer)
export const machineCredentials = sqliteTable("machine_credentials", {
machineId: text("machine_id").primaryKey().references(() => machines.id, { onDelete: "cascade" }),
authMethod: text("auth_method").notNull(), // password | ssh_key
encPassword: text("enc_password"),
encSudoPassword: text("enc_sudo_password"),
encPrivateKey: text("enc_private_key"),
encKeyPassphrase: text("enc_key_passphrase"),
sudoMode: text("sudo_mode").notNull(), // same_as_ssh | separate | none
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
lastTestAt: text("last_test_at"),
status: text("status"), // ok | error | unknown
});
export const machineHostKeys = sqliteTable("machine_host_keys", {
id: text("id").primaryKey(),
machineId: text("machine_id").references(() => machines.id, { onDelete: "cascade" }),
hostname: text("hostname").notNull(),
port: integer("port").notNull(),
keyType: text("key_type"),
fingerprintSha256: text("fingerprint_sha256").notNull(),
publicKey: text("public_key"),
status: text("status").notNull(), // approved | changed | rejected | unknown
firstSeenAt: text("first_seen_at").notNull(),
lastSeenAt: text("last_seen_at").notNull(),
});
- Step 3 : Générer la migration
Run: rtk pnpm db:generate
Expected: server/db/migrations/0003_*.sql (CREATE TABLE machine_credentials + machine_host_keys uniquement). Vérifier qu'aucun DROP ni recréation de table existante n'apparaît (sinon corriger le schéma et régénérer).
- Step 4 : Étendre
server/db/schema.test.ts— ajouter un test
it("crée les tables de credentials Phase 2", () => {
const sqlite = freshMigratedDb();
const tables = tableNames(sqlite);
expect(tables).toEqual(expect.arrayContaining(["machine_credentials", "machine_host_keys"]));
// machines conserve ses colonnes secrets legacy (fallback)
expect(columnNames(sqlite, "machines")).toContain("enc_password");
});
-
Step 5 : Run
rtk pnpm vitest run server/db/schema.test.ts→ PASS. Puisrtk pnpm check→ 0 erreur. -
Step 6 : (pas de commit)
Task 2 : Service credentials (write / read / backfill) — TDD
Files: Create server/services/credentials.ts, server/services/credentials.test.ts.
- Step 1 : Test (échec attendu) —
server/services/credentials.test.ts
import { describe, it, expect } from "vitest";
import { resolveCreds } from "./credentials.js";
describe("resolveCreds", () => {
it("préfère la ligne machine_credentials", () => {
const out = resolveCreds(
{ encPassword: "M_PWD", encSudoPassword: null }, // machines (legacy)
{ encPassword: "C_PWD", encSudoPassword: "C_SUDO" }, // machine_credentials
);
expect(out).toEqual({ encPassword: "C_PWD", encSudoPassword: "C_SUDO" });
});
it("retombe sur machines si pas de credentials", () => {
const out = resolveCreds({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" }, null);
expect(out).toEqual({ encPassword: "M_PWD", encSudoPassword: "M_SUDO" });
});
});
-
Step 2 : Run
rtk pnpm vitest run server/services/credentials.test.ts→ FAIL (module manquant). -
Step 3 : Implémenter
server/services/credentials.ts
// server/services/credentials.ts
import { eq } from "drizzle-orm";
import { db, schema } from "../db/client.js";
interface EncPair { encPassword: string | null; encSudoPassword: string | null; }
/** Résout la source des secrets : machine_credentials prioritaire, sinon legacy machines (fonction pure). */
export function resolveCreds(legacy: EncPair, creds: EncPair | null): EncPair {
if (creds && creds.encPassword) return { encPassword: creds.encPassword, encSudoPassword: creds.encSudoPassword };
return { encPassword: legacy.encPassword, encSudoPassword: legacy.encSudoPassword };
}
/** Écrit (insert/replace) la ligne machine_credentials pour une machine (secrets déjà chiffrés). */
export function writeCredentials(input: {
machineId: string;
encPassword: string | null;
encSudoPassword: string | null;
}): void {
const now = new Date().toISOString();
db.insert(schema.machineCredentials)
.values({
machineId: input.machineId,
authMethod: "password",
encPassword: input.encPassword,
encSudoPassword: input.encSudoPassword,
sudoMode: input.encSudoPassword ? "separate" : "same_as_ssh",
createdAt: now,
updatedAt: now,
status: "unknown",
})
.onConflictDoUpdate({
target: schema.machineCredentials.machineId,
set: { encPassword: input.encPassword, encSudoPassword: input.encSudoPassword, updatedAt: now },
})
.run();
}
/** Lit la ligne machine_credentials (ou null). */
export function readCredentials(machineId: string): EncPair | null {
const row = db.select().from(schema.machineCredentials)
.where(eq(schema.machineCredentials.machineId, machineId)).get();
return row ? { encPassword: row.encPassword, encSudoPassword: row.encSudoPassword } : null;
}
/** Backfill idempotent : crée une ligne machine_credentials pour chaque machine qui n'en a pas. */
export function backfillCredentials(): number {
const machines = db.select().from(schema.machines).all();
let created = 0;
for (const m of machines) {
if (readCredentials(m.id)) continue;
writeCredentials({ machineId: m.id, encPassword: m.encPassword, encSudoPassword: m.encSudoPassword });
created++;
}
return created;
}
-
Step 4 : Run
rtk pnpm vitest run server/services/credentials.test.ts→ PASS (2). (Le test n'appelle queresolveCreds, pur ; pas de DB.) -
Step 5 : (pas de commit)
Task 3 : Brancher dans machines.ts + backfill au démarrage
Files: Modify server/services/machines.ts, server/db/migrate.ts.
-
Step 1 : Relire
server/services/machines.ts(état réel :getCreds,createMachine). -
Step 2 :
createMachineécrit aussi machine_credentials
Importer en tête : import { writeCredentials, readCredentials, resolveCreds } from "./credentials.js";
Après l'insert de la ligne machines (avant return toView(row)), ajouter :
writeCredentials({ machineId: id, encPassword: row.encPassword, encSudoPassword: row.encSudoPassword });
(On conserve aussi l'écriture dans machines.enc_password — non destructif.)
- Step 3 :
getCredslit machine_credentials en priorité
Remplacer le corps de getCreds par :
export function getCreds(m: MachineRow): SshCreds {
const key = env.requireMasterKey();
const { encPassword, encSudoPassword } = resolveCreds(
{ encPassword: m.encPassword, encSudoPassword: m.encSudoPassword },
readCredentials(m.id),
);
if (!encPassword) throw new Error("Aucun secret pour cette machine");
return {
hostname: m.hostname,
port: m.port,
username: m.username,
password: decryptSecret(encPassword, key),
sudoPassword: encSudoPassword ? decryptSecret(encSudoPassword, key) : null,
};
}
- Step 4 : Backfill au démarrage — dans
server/db/migrate.ts, aprèsrunMigrations(), exposer et appeler le backfill. ModifierrunMigrationspour enchaîner :
// server/db/migrate.ts
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { db } from "./client.js";
import { backfillCredentials } from "../services/credentials.js";
export function runMigrations(): void {
migrate(db, { migrationsFolder: "./server/db/migrations" });
const n = backfillCredentials();
if (n > 0) console.log(`[migrate] backfill credentials: ${n} machine(s)`);
}
-
Step 5 : Run
rtk pnpm check && rtk pnpm test→ 0 erreur TS ; tests verts (48 attendus : +2 credentials). Si un test hors périmètre (WIP concurrent) casse, le signaler sans corriger. -
Step 6 : (pas de commit)
Task 4 : Vérification finale Phase 2
-
Step 1 :
rtk pnpm check && rtk pnpm test && rtk pnpm build→ tout vert +distproduit. -
Step 2 : Boot + backfill + tables
export SU_MASTER_KEY=$(openssl rand -hex 32) SU_DB_PATH=./data/p2.db SU_REPORTS_DIR=./data/p2-reports
node dist/index.js > ./data/p2.log 2>&1 &
sleep 3
curl -s localhost:8787/health
node -e "const D=require('better-sqlite3');const db=new D('./data/p2.db');console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'machine_%'\").all().map(r=>r.name).join(', '));"
kill %1 2>/dev/null
rm -rf ./data/p2.db* ./data/p2-reports ./data/p2.log
Expected: {"ok":true} ; tables incluent machine_credentials, machine_host_keys. (Backfill = 0 sur DB neuve, normal.)
- Step 3 : Reporter à l'utilisateur (tables ajoutées, dual-read/backfill, non-régression). Ne pas committer.
Self-Review (couverture tache1.9 §14 Phase 2)
- créer
machine_credentials→ Task 1. ✓ - migrer
enc_password/enc_sudo_password→ approche non destructive : dual-write + backfill + lecture prioritaire (Tasks 2-3). Les colonnes legacy restent comme fallback (drop = phase ultérieure de nettoyage). ✓ - créer
machine_host_keys→ Task 1 (schéma ; vérification host key = logique ultérieure). ✓ - audit événements secrets → léger : non inclus en Phase 2 (le
recordEventPhase 1 existe ; l'audit systématique des déchiffrements relève de tâche 7 sécurité). Noté comme suite.
Décision assumée : non destructif (pas de DROP des colonnes secrets de machines) pour protéger les machines réelles existantes. Noms cohérents : resolveCreds/writeCredentials/readCredentials/backfillCredentials définis Task 2, utilisés Task 3.