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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user