feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user