Files
MikrotikManager/backend/src/modules/mcp/diagnostic-cancellation-source.ts
T

124 lines
9.7 KiB
TypeScript

import { z } from "zod"
import type { ProbeStorageScope, ProbeStorageSql } from "../../services/server-probe-journal-maintenance.js"
import type { ServerProbeJournalContext } from "../../services/server-probe-journal.js"
import { SERVER_PROBE_OPERATION } from "../../services/server-probe-authority.js"
import { openServerProbeMcpBinding } from "./server-probe-actions.js"
import { decryptSecret, fingerprint, privateSecretFingerprint, redact, McpError } from "./security.js"
const MAX_BYTES = 2097152
const uuid = z.string().uuid()
const deny = (): never => { throw new McpError("STATE_CONFLICT", "Исходная привязка отмены диагностики не подтверждена", 409) }
const sealedSchema = z.object({ sealed: z.string().min(1).max(MAX_BYTES) }).strict()
interface SourceCrypto { open(value: string): Promise<string>; proof(value: unknown): Promise<string> }
export interface DiagnosticCancellationOwner { connectionId: string; ownerId: string }
export interface DiagnosticCancellationSource {
readonly scope: Readonly<ProbeStorageScope>
readonly sourceDigest: string
readonly sourceState: string
readonly terminal: boolean
readonly serverIds: readonly number[]
readonly stageBudget: number
readonly originalCredentialId: string
}
export interface ServerProbeExecutionSource extends DiagnosticCancellationSource {
readonly originalContext: Readonly<ServerProbeJournalContext> & { readonly identity: Readonly<Extract<ServerProbeJournalContext["identity"], { kind: "mcp" }>> }
}
const productionCrypto: SourceCrypto = {
open: decryptSecret,
proof: value => privateSecretFingerprint(fingerprint(value), "server-probe-mcp-binding-v1"),
}
const sourceSelect = `/* diagnostic-cancellation:source */
SELECT o.id AS operation_id,o.connection_id,c.owner_id,o.state AS operation_state,
p.operation_id AS operation_name,p.operation_version,p.state AS plan_state,p.approved_by,
CASE WHEN octet_length(p.evidence::text)<=2097152 THEN p.evidence ELSE NULL END AS evidence,
s.step_index,s.state AS step_state,s.intended_state
FROM mcp_operations o JOIN mcp_plans p ON p.id=o.plan_id AND p.connection_id=o.connection_id
JOIN mcp_connections c ON c.id=o.connection_id
LEFT JOIN mcp_operation_steps s ON s.operation_id=o.id
WHERE o.id=$1 AND o.connection_id=$2 AND c.owner_id=$3 LIMIT 2`
/** SQL-free authenticated historical decoding; deliberately no current-access
* check, clock or native/source lookup. Expired original contexts stay expired. */
async function validateSourceRow(row: Record<string, unknown>, operationId: string, owner: Readonly<DiagnosticCancellationOwner>, crypto: SourceCrypto): Promise<Readonly<ServerProbeExecutionSource>> {
if (row.operation_id !== operationId || row.connection_id !== owner.connectionId || row.owner_id !== owner.ownerId || row.operation_name !== SERVER_PROBE_OPERATION || row.operation_version !== 1 || typeof row.approved_by !== "string" || !row.approved_by.trim()) return deny()
const wrapper = sealedSchema.safeParse(row.evidence)
if (!wrapper.success || Buffer.byteLength(JSON.stringify(row.evidence), "utf8") > MAX_BYTES) return deny()
let evidence: Record<string, unknown>
try {
const plaintext = await crypto.open(wrapper.data.sealed)
if (Buffer.byteLength(plaintext, "utf8") > MAX_BYTES) return deny()
const parsed: unknown = JSON.parse(plaintext)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return deny()
evidence = parsed as Record<string, unknown>
} catch { return deny() }
const steps = z.array(z.object({ kind: z.literal("service"), service: z.literal(SERVER_PROBE_OPERATION), input: z.record(z.string(), z.unknown()) }).strict()).length(1).safeParse(evidence.steps)
if (!steps.success) return deny()
const step = steps.data[0]
let opened: Awaited<ReturnType<typeof openServerProbeMcpBinding>>
try { opened = await openServerProbeMcpBinding(step.input, crypto) } catch { return deny() }
const { binding, input } = opened
const serverIds = [...new Set([binding.prepared.serverId, ...(binding.prepared.destinationServerId !== undefined ? [binding.prepared.destinationServerId] : [])])].sort((a,b) => a-b)
const expectedBudget = binding.prepared.tool === "mtu" ? 11 : ["ping", "traceroute"].includes(binding.prepared.tool) ? 2 : 1
if (binding.ownerId !== owner.ownerId || binding.principal.connectionId !== owner.connectionId || binding.stageBudget !== expectedBudget || evidence.mutationCost !== binding.stageBudget || evidence.requiresHuman !== true || evidence.verificationScope !== "acknowledgement" || evidence.validUntil !== binding.prepared.expiresAt || fingerprint(evidence.target) !== fingerprint({ scope: "servers", serverIds })) return deny()
const state = z.enum(["pending", "running", "applied", "unknown", "failed", "cancelled", "rejected", "partial"]).safeParse(row.operation_state)
if (!state.success) return deny()
const terminal = state.data !== "pending" && state.data !== "running"
// Queued operations have no step until dispatch in the current runner.
const absentStep = row.step_index === null && row.step_state === null && row.intended_state === null
if (state.data === "pending" ? row.plan_state !== "executing" || (!absentStep && row.step_state !== "pending") : state.data === "running" ? row.plan_state !== "executing" || row.step_state !== "intent" : !["executing", "completed", "cancelled", "expired", "invalidated"].includes(String(row.plan_state))) return deny()
if (!absentStep && (row.step_index !== 0 || fingerprint(row.intended_state) !== fingerprint(redact({ kind: "service", service: SERVER_PROBE_OPERATION, input })))) return deny()
if (state.data === "running" && absentStep) return deny()
const scope: Readonly<ProbeStorageScope> = Object.freeze({ ownerId: binding.ownerId, receiptId: input.receiptId, identityKind: "mcp", executionId: operationId, bindingProof: binding.bindingProof, authorityExpiresAt: new Date(binding.authorityExpiresAt).toISOString() })
const sourceDigest = await crypto.proof({ operationId, connectionId: owner.connectionId, ownerId: owner.ownerId, evidence: wrapper.data, intendedState: row.intended_state, approvedBy: row.approved_by })
const identity = Object.freeze({ kind: "mcp" as const, ownerId: binding.ownerId, executionId: operationId,
connectionId: binding.principal.connectionId, credentialId: binding.principal.credentialId,
issuedAt: binding.issuedAt, expiresAt: binding.authorityExpiresAt, operationName: SERVER_PROBE_OPERATION })
const originalContext = Object.freeze({ identity, sourceServerId: binding.prepared.serverId,
...(binding.prepared.destinationServerId !== undefined ? { destinationServerId: binding.prepared.destinationServerId } : {}), tool: binding.prepared.tool })
return Object.freeze({ scope, sourceDigest, sourceState: state.data, terminal, serverIds: Object.freeze(serverIds), stageBudget: binding.stageBudget, originalCredentialId: binding.principal.credentialId, originalContext })
}
function checkLookup(operationId: string, owner: Readonly<DiagnosticCancellationOwner>) {
uuid.parse(operationId); uuid.parse(owner.connectionId)
if (!owner.ownerId || owner.ownerId.length > 200) return deny()
}
function sourceLoader(crypto: SourceCrypto) {
return async (client: ProbeStorageSql, operationId: string, owner: Readonly<DiagnosticCancellationOwner>): Promise<Readonly<DiagnosticCancellationSource>> => {
checkLookup(operationId, owner)
// Existing cancellation path retains the host-owned transaction and locks.
const { rows } = await client.query(sourceSelect + " FOR UPDATE OF o,p,c", [operationId, owner.connectionId, owner.ownerId])
if (rows.length !== 1) return deny()
const source = await validateSourceRow(rows[0], operationId, owner, crypto)
// Preserve the existing cancellation result; historical context is private
// to the explicit read-only loader, not an expanded cancellation response.
return Object.freeze({ scope: source.scope, sourceDigest: source.sourceDigest, sourceState: source.sourceState,
terminal: source.terminal, serverIds: source.serverIds, stageBudget: source.stageBudget, originalCredentialId: source.originalCredentialId })
}
}
function readSourceLoader(crypto: SourceCrypto) {
return async (client: ProbeStorageSql, operationId: string, owner: Readonly<DiagnosticCancellationOwner>): Promise<Readonly<ServerProbeExecutionSource>> => {
checkLookup(operationId, owner)
// Fixed SELECT without row locks. The trusted caller owns the bounded
// READ ONLY REPEATABLE READ snapshot; no SQL/lock-mode flag is accepted.
const { rows } = await client.query(sourceSelect, [operationId, owner.connectionId, owner.ownerId])
if (rows.length !== 1) return deny()
return validateSourceRow(rows[0], operationId, owner, crypto)
}
}
/** Internal host boundary: authenticate source only; host must check CURRENT
* cancellation authority separately before any durable cancellation write. */
export const loadDiagnosticCancellationSource = sourceLoader(productionCrypto)
/** Test-only crypto seam. Never accept these dependencies from tool JSON. */
export const createDiagnosticCancellationSourceForTests = sourceLoader
/** Historical authentication only; confers no execution/cancellation grant. */
export const readServerProbeExecutionSource = readSourceLoader(productionCrypto)
export const createReadServerProbeExecutionSourceForTests = readSourceLoader
/** Exact previously locked source projection for private history conversion. */
export function authenticateServerProbeExecutionSourceRow(row: Record<string, unknown>, operationId: string,
owner: Readonly<DiagnosticCancellationOwner>) {
checkLookup(operationId, owner)
return validateSourceRow(row, operationId, owner, productionCrypto)
}