202 lines
6.4 KiB
TypeScript
202 lines
6.4 KiB
TypeScript
/**
|
|
* SSH audit log: dedicated table with pending → completed/failed/denied/aborted lifecycle.
|
|
*
|
|
* Design rationale (codex review CRITICAL #6):
|
|
* Tool operations begin a pending audit row, commit, THEN issue the remote call.
|
|
* If the orchestrator crashes mid-call, the pending row remains and is reconciled
|
|
* to 'aborted' on next boot (see ssh-recovery.ts). This gives us "execution
|
|
* happened, outcome unknown" forensics rather than "no record at all".
|
|
*
|
|
* All write operations are inside a transaction (better-sqlite3 default for prepare+run
|
|
* with .transaction wrappers when needed).
|
|
*
|
|
* Plan: docs/superpowers/plans/2026-05-12-ssh-tool-integration.md (Phase 1).
|
|
*/
|
|
import type Database from 'better-sqlite3';
|
|
|
|
export type SshAuditOutcome = 'pending' | 'success' | 'failed' | 'denied' | 'aborted';
|
|
|
|
export interface BeginAuditInput {
|
|
action: string;
|
|
entityType?: string;
|
|
entityId?: string;
|
|
connectionId?: string;
|
|
ownerId?: string | null;
|
|
actingUserId?: string;
|
|
jobId?: string;
|
|
pieceName?: string;
|
|
reason?: string;
|
|
detail?: Record<string, unknown>;
|
|
/** ISO8601 timestamp; defaults to now. */
|
|
startedAt?: string;
|
|
}
|
|
|
|
export interface SshAuditRow {
|
|
id: number;
|
|
action: string;
|
|
entityType: string | null;
|
|
entityId: string | null;
|
|
connectionId: string | null;
|
|
ownerId: string | null;
|
|
actingUserId: string | null;
|
|
jobId: string | null;
|
|
pieceName: string | null;
|
|
outcome: SshAuditOutcome;
|
|
reason: string | null;
|
|
detail: Record<string, unknown> | null;
|
|
startedAt: string;
|
|
completedAt: string | null;
|
|
}
|
|
|
|
interface RawRow {
|
|
id: number;
|
|
action: string;
|
|
entity_type: string | null;
|
|
entity_id: string | null;
|
|
connection_id: string | null;
|
|
owner_id: string | null;
|
|
acting_user_id: string | null;
|
|
job_id: string | null;
|
|
piece_name: string | null;
|
|
outcome: SshAuditOutcome;
|
|
reason: string | null;
|
|
detail: string | null;
|
|
started_at: string;
|
|
completed_at: string | null;
|
|
}
|
|
|
|
function fromRow(r: RawRow): SshAuditRow {
|
|
return {
|
|
id: r.id,
|
|
action: r.action,
|
|
entityType: r.entity_type,
|
|
entityId: r.entity_id,
|
|
connectionId: r.connection_id,
|
|
ownerId: r.owner_id,
|
|
actingUserId: r.acting_user_id,
|
|
jobId: r.job_id,
|
|
pieceName: r.piece_name,
|
|
outcome: r.outcome,
|
|
reason: r.reason,
|
|
detail: r.detail ? (JSON.parse(r.detail) as Record<string, unknown>) : null,
|
|
startedAt: r.started_at,
|
|
completedAt: r.completed_at,
|
|
};
|
|
}
|
|
|
|
export interface SshAuditRepo {
|
|
/** Insert a pending audit row. Commits before returning (synchronous via better-sqlite3). */
|
|
begin(input: BeginAuditInput): number;
|
|
/**
|
|
* Update an existing pending row to a terminal outcome. Idempotent: if the row
|
|
* was already completed, this is a no-op (returns false). Returns true if the
|
|
* row was 'pending' and was updated.
|
|
*/
|
|
complete(id: number, outcome: Exclude<SshAuditOutcome, 'pending'>, detail?: Record<string, unknown>): boolean;
|
|
/** Convenience: begin + complete in one call, for actions with no remote operation. */
|
|
beginAndComplete(input: BeginAuditInput, outcome: Exclude<SshAuditOutcome, 'pending'>): number;
|
|
listForConnection(connectionId: string, limit?: number): SshAuditRow[];
|
|
listForOwner(ownerId: string, limit?: number): SshAuditRow[];
|
|
listPending(): SshAuditRow[];
|
|
getById(id: number): SshAuditRow | null;
|
|
}
|
|
|
|
export function createAuditRepo(db: Database.Database): SshAuditRepo {
|
|
const insertStmt = db.prepare(`
|
|
INSERT INTO ssh_audit_log (
|
|
action, entity_type, entity_id, connection_id, owner_id,
|
|
acting_user_id, job_id, piece_name, outcome, reason, detail,
|
|
started_at, completed_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, NULL)
|
|
`);
|
|
|
|
const completeStmt = db.prepare(`
|
|
UPDATE ssh_audit_log
|
|
SET outcome = ?, detail = ?, completed_at = ?
|
|
WHERE id = ? AND outcome = 'pending'
|
|
`);
|
|
|
|
const selectByIdStmt = db.prepare(`SELECT * FROM ssh_audit_log WHERE id = ?`);
|
|
const selectByConnStmt = db.prepare(
|
|
`SELECT * FROM ssh_audit_log WHERE connection_id = ? ORDER BY started_at DESC LIMIT ?`,
|
|
);
|
|
const selectByOwnerStmt = db.prepare(
|
|
`SELECT * FROM ssh_audit_log WHERE owner_id = ? ORDER BY started_at DESC LIMIT ?`,
|
|
);
|
|
const selectPendingStmt = db.prepare(
|
|
`SELECT * FROM ssh_audit_log WHERE outcome = 'pending' ORDER BY started_at ASC`,
|
|
);
|
|
|
|
function begin(input: BeginAuditInput): number {
|
|
const startedAt = input.startedAt ?? new Date().toISOString();
|
|
const detailJson = input.detail ? JSON.stringify(input.detail) : null;
|
|
const result = insertStmt.run(
|
|
input.action,
|
|
input.entityType ?? null,
|
|
input.entityId ?? null,
|
|
input.connectionId ?? null,
|
|
input.ownerId ?? null,
|
|
input.actingUserId ?? null,
|
|
input.jobId ?? null,
|
|
input.pieceName ?? null,
|
|
input.reason ?? null,
|
|
detailJson,
|
|
startedAt,
|
|
);
|
|
return result.lastInsertRowid as number;
|
|
}
|
|
|
|
function complete(
|
|
id: number,
|
|
outcome: Exclude<SshAuditOutcome, 'pending'>,
|
|
detail?: Record<string, unknown>,
|
|
): boolean {
|
|
// Merge detail with any existing detail (caller-supplied wins for overlapping keys).
|
|
let mergedDetail: string | null = null;
|
|
const existing = selectByIdStmt.get(id) as RawRow | undefined;
|
|
if (existing) {
|
|
const existingDetail = existing.detail
|
|
? (JSON.parse(existing.detail) as Record<string, unknown>)
|
|
: {};
|
|
const merged: Record<string, unknown> = { ...existingDetail, ...(detail ?? {}) };
|
|
mergedDetail = JSON.stringify(merged);
|
|
} else if (detail) {
|
|
mergedDetail = JSON.stringify(detail);
|
|
}
|
|
const completedAt = new Date().toISOString();
|
|
const r = completeStmt.run(outcome, mergedDetail, completedAt, id);
|
|
return r.changes > 0;
|
|
}
|
|
|
|
function beginAndComplete(
|
|
input: BeginAuditInput,
|
|
outcome: Exclude<SshAuditOutcome, 'pending'>,
|
|
): number {
|
|
return db.transaction(() => {
|
|
const id = begin(input);
|
|
complete(id, outcome);
|
|
return id;
|
|
})();
|
|
}
|
|
|
|
return {
|
|
begin,
|
|
complete,
|
|
beginAndComplete,
|
|
listForConnection(connectionId, limit = 50) {
|
|
return (selectByConnStmt.all(connectionId, limit) as RawRow[]).map(fromRow);
|
|
},
|
|
listForOwner(ownerId, limit = 50) {
|
|
return (selectByOwnerStmt.all(ownerId, limit) as RawRow[]).map(fromRow);
|
|
},
|
|
listPending() {
|
|
return (selectPendingStmt.all() as RawRow[]).map(fromRow);
|
|
},
|
|
getById(id) {
|
|
const r = selectByIdStmt.get(id) as RawRow | undefined;
|
|
return r ? fromRow(r) : null;
|
|
},
|
|
};
|
|
}
|