Files
system_update/docs/superpowers/plans/2026-06-05-tache1.9-phase2-credentials.md
gilles 0fbca06d3d docs: roadmap tâches 1.9-8 (briefs, gates de validation, designs tâche 2) + plans d'implémentation
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>
2026-06-05 19:50:25 +02:00

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_credentials n'est JAMAIS exposée via l'API publique (la MachineView reste sans secret).
  • Rétro-compatibilité : une machine sans ligne machine_credentials reste 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.ts ou server/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ès apiClients, 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. Puis rtk 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 que resolveCreds, 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 : getCreds lit 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ès runMigrations(), exposer et appeler le backfill. Modifier runMigrations pour 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 + dist produit.

  • 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 recordEvent Phase 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.