0fbca06d3d
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>
270 lines
12 KiB
Markdown
270 lines
12 KiB
Markdown
# 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)
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
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`
|
|
|
|
```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`**
|
|
|
|
```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 :
|
|
```ts
|
|
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 :
|
|
```ts
|
|
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 :
|
|
```ts
|
|
// 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**
|
|
```bash
|
|
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.
|