124 lines
9.7 KiB
TypeScript
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)
|
|
}
|