Files
MikrotikManager/backend/src/services/server-probe-authority.ts
T

103 lines
8.8 KiB
TypeScript

import type { AuthUser } from "../lib/permissions.js"
import { hasPermission } from "../lib/permissions.js"
import { pool } from "../db/index.js"
import { effectiveMcpConnection, type McpPrincipal } from "../modules/mcp/store.js"
import { assertPortalClaimsCurrent } from "../modules/mcp/portal-revocation.js"
import { fingerprint, McpError } from "../modules/mcp/security.js"
import { assertVerifiedConfigurationAccess } from "./config-mutation-policy.js"
import type { ServerProbeAuthority } from "./server-probe-execution.js"
export const SERVER_PROBE_OPERATION = "servers.probes.run"
export const SERVER_PROBE_RECEIPT_READ_OPERATION = "servers.probes.receipt.read"
const fail = (message: string, code = "PERMISSION_DENIED", status = 403): never => { throw new McpError(code, message, status) }
const production = {
connection: effectiveMcpConnection,
portal: assertPortalClaimsCurrent,
verified: assertVerifiedConfigurationAccess,
now: Date.now,
query: async (sql: string, values: unknown[]) => (await pool.query(sql, values)).rows as Record<string, unknown>[],
}
export type ServerProbeAuthorityDependencies = typeof production
function live(signal: AbortSignal | undefined, expiresAt: number, now: number) {
if (signal?.aborted) fail("Диагностика отменена", "READ_CANCELLED", 409)
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) fail("Срок допуска диагностики истёк")
}
function ids(value: unknown): number[] {
if (!Array.isArray(value) || value.length > 1000 || value.some(id => !Number.isSafeInteger(id) || id <= 0) || new Set(value).size !== value.length) return fail("Полная область диагностики недоступна", "STATE_UNAVAILABLE", 503)
return [...value].sort((a, b) => a - b)
}
/** Trusted constructors only. None of the transport/actor/admission options is
* accepted from a tool's or REST request's JSON. No I/O occurs at construction. */
function createMcpProbeAuthority(principal: McpPrincipal, options: { signal?: AbortSignal; executing?: boolean }, overrides: Partial<ServerProbeAuthorityDependencies>, permission: "mm:servers:read" | "mm:servers:write", operation: string) {
const deps = { ...production, ...overrides }
const readAuthority = async (): Promise<ServerProbeAuthority> => {
live(options.signal, Date.parse(principal.credentialExpiresAt), deps.now())
if (options.executing) await deps.verified()
const current = await deps.connection(principal)
const expiresAt = Math.min(Date.parse(principal.credentialExpiresAt), Date.parse(current.policy.expiresAt), current.owner_valid_until.getTime())
live(options.signal, expiresAt, deps.now())
if (current.id !== principal.connectionId || current.policy.status !== "active" || permission === "mm:servers:write" && current.policy.mode === "observer" || !current.policy.scope.operationIds.includes(operation) || !hasPermission(current.owner_permissions, permission) || !hasPermission(current.policy.permissions, permission)) return fail("Нет актуального права серверной диагностики")
return { transport: "mcp", ownerId: current.owner_id, connectionId: current.id, credentialId: principal.credentialId, expiresAt, generation: current.generation,
policyProof: fingerprint(current.policy), ownerProof: fingerprint(current.owner_permissions), active: true, operationAllowed: true,
scopeServerIds: ids(current.policy.scope.serverIds), ownerPermissions: [...current.owner_permissions], policyPermissions: [...current.policy.permissions] }
}
return { readAuthority, assertProtections: protectionReader(readAuthority, deps, true) }
}
export function createMcpServerProbeAuthority(principal: McpPrincipal, options: { signal?: AbortSignal; executing?: boolean } = {}, overrides: Partial<ServerProbeAuthorityDependencies> = {}) {
return createMcpProbeAuthority(principal, options, overrides, "mm:servers:write", SERVER_PROBE_OPERATION)
}
/** Historical public measurements use today's read grant, never an expired
* execution credential. This cannot prepare or run a diagnostic command. */
export function createMcpServerProbeReadAuthority(principal: McpPrincipal, options: { signal?: AbortSignal } = {}, overrides: Partial<ServerProbeAuthorityDependencies> = {}) {
return createMcpProbeAuthority(principal, options, overrides, "mm:servers:read", SERVER_PROBE_RECEIPT_READ_OPERATION)
}
export function createRestServerProbeAuthority(actor: AuthUser | undefined, request: { requestId: string; acceptedAt: number; authRequired: boolean; signal?: AbortSignal }, overrides: Partial<ServerProbeAuthorityDependencies> = {}, resolveServerIds?: () => Promise<readonly number[]>) {
const deps = { ...production, ...overrides }
if (!request.requestId || request.requestId.length > 256 || !Number.isSafeInteger(request.acceptedAt) || request.acceptedAt <= 0) return fail("Не подтверждён HTTP-контекст диагностики", "INVALID_INPUT", 400)
// Open standalone mode is a server configuration, never a caller flag. Its
// local actor is recorded as REST; it cannot manufacture an MCP connection.
const local = !request.authRequired && !actor
if (!local && (!actor || !actor.id || !hasPermission(actor.permissions, "mm:servers:write"))) return fail("Нет права серверной диагностики")
const issuedAt = actor?.issuedAt ?? request.acceptedAt, expiresAt = Math.min(actor?.expiresAt ?? Infinity, request.acceptedAt + 300000)
const portalAccess = async () => {
if (actor) {
if (!hasPermission(actor.permissions, "mm:servers:write")) return fail("Право серверной диагностики отозвано")
await deps.portal(actor)
if (!hasPermission(actor.permissions, "mm:servers:write")) return fail("Право серверной диагностики отозвано")
}
}
const readAuthority = async (): Promise<ServerProbeAuthority> => {
live(request.signal, expiresAt, deps.now())
if (issuedAt > deps.now()) return fail("JWT диагностики выдан в будущем")
await portalAccess()
live(request.signal, expiresAt, deps.now())
// JWT write permission applies to all servers. Read bounded IDs only, never
// credentials, before establishing the complete allowed source set.
const serverIds = resolveServerIds ? ids([...await resolveServerIds()]) : ids((await deps.query("SELECT id FROM servers ORDER BY id LIMIT 1001", [])).map(row => row.id))
await portalAccess()
live(request.signal, expiresAt, deps.now())
const permissions = local ? ["mm:servers:write"] : [...actor!.permissions]
return { transport: "rest", requestId: request.requestId, issuedAt, ownerId: actor?.id ?? "local-operator", expiresAt, generation: fingerprint({ kind: local ? "local-request" : "portal-jwt", requestId: request.requestId, issuedAt, expiresAt }),
policyProof: fingerprint({ transport: "rest", authentication: local ? "local" : "jwt", operation: SERVER_PROBE_OPERATION, serverIds, expiresAt }), ownerProof: fingerprint(permissions),
active: true, operationAllowed: true, scopeServerIds: serverIds, ownerPermissions: permissions, policyPermissions: [...permissions] }
}
return { readAuthority, assertProtections: protectionReader(readAuthority, deps, false) }
}
function protectionReader(readAuthority: () => Promise<ServerProbeAuthority>, deps: ServerProbeAuthorityDependencies, humanApprovedPlan: boolean) {
return async (serverIds: number[], expected: ServerProbeAuthority): Promise<void> => {
const requested = ids(serverIds), before = await readAuthority()
if (fingerprint(before) !== fingerprint(expected) || requested.some(id => !before.scopeServerIds.includes(id))) return fail("Допуск участников диагностики изменился", "STATE_CONFLICT", 409)
const rows = await deps.query("SELECT object_ref FROM mcp_protections WHERE object_ref->>'serverId'=ANY($1::text[]) OR (object_ref->>'kind'='server' AND object_ref->>'id'=ANY($1::text[])) LIMIT 1001", [requested.map(String)])
const after = await readAuthority()
if (fingerprint(before) !== fingerprint(after)) return fail("Допуск изменился во время проверки защит", "STATE_CONFLICT", 409)
if (rows.length > 1000) return fail("Каталог защит превышает предел", "STATE_UNAVAILABLE", 503)
for (const row of rows) {
const ref = row.object_ref as { protection?: unknown } | null
if (!ref || !["deny", "human-approval-required"].includes(String(ref.protection))) return fail("Неизвестная защита диагностики", "STATE_UNAVAILABLE", 503)
if (ref.protection === "deny" || !humanApprovedPlan) return fail("Диагностика заблокирована защитой; нужен разрешённый MCP-план", ref.protection === "deny" ? "PROTECTED_OBJECT" : "APPROVAL_REQUIRED", 409)
}
}
}