MCP um dauerhafte tenantgebundene Tokens erweitern
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { FastifyInstance } from "fastify"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { createHash, randomBytes } from "node:crypto"
|
||||
import { m2mApiKeys } from "../../db/schema"
|
||||
import { assertToolPermission, createMcpContext } from "../mcp/authz"
|
||||
import { mcpToolMap, mcpTools } from "../mcp/registry"
|
||||
import { asToolError, asToolResult } from "../mcp/result"
|
||||
@@ -38,7 +41,160 @@ function selectProtocolVersion(clientVersion?: string) {
|
||||
return SUPPORTED_PROTOCOL_VERSIONS[0]
|
||||
}
|
||||
|
||||
const hashApiKey = (apiKey: string) =>
|
||||
createHash("sha256").update(apiKey, "utf8").digest("hex")
|
||||
|
||||
const createMcpToken = () => `fedeo_mcp_${randomBytes(32).toString("base64url")}`
|
||||
|
||||
const requireMcpTokenManagementPermission = (req: any) => {
|
||||
if (req.mcpTokenAuth) {
|
||||
throw Object.assign(new Error("MCP Tokens können nur mit einer Benutzersitzung verwaltet werden"), { statusCode: 403 })
|
||||
}
|
||||
|
||||
if (req.user?.is_admin) return
|
||||
if (typeof req.hasPermission === "function" && req.hasPermission("mcp.tokens.write")) return
|
||||
|
||||
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
|
||||
}
|
||||
|
||||
export default async function mcpRoutes(server: FastifyInstance) {
|
||||
server.get("/mcp/tokens", async (req, reply) => {
|
||||
try {
|
||||
requireMcpTokenManagementPermission(req)
|
||||
|
||||
if (!req.user?.tenant_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const rows = await server.db
|
||||
.select({
|
||||
id: m2mApiKeys.id,
|
||||
createdAt: m2mApiKeys.createdAt,
|
||||
updatedAt: m2mApiKeys.updatedAt,
|
||||
name: m2mApiKeys.name,
|
||||
keyPrefix: m2mApiKeys.keyPrefix,
|
||||
active: m2mApiKeys.active,
|
||||
lastUsedAt: m2mApiKeys.lastUsedAt,
|
||||
expiresAt: m2mApiKeys.expiresAt,
|
||||
userId: m2mApiKeys.userId,
|
||||
createdBy: m2mApiKeys.createdBy,
|
||||
})
|
||||
.from(m2mApiKeys)
|
||||
.where(and(
|
||||
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||
))
|
||||
.orderBy(desc(m2mApiKeys.createdAt))
|
||||
|
||||
return { rows: rows.filter((row) => row.keyPrefix.startsWith("fedeo_mcp_")) }
|
||||
} catch (error) {
|
||||
const statusCode = (error as any)?.statusCode || 500
|
||||
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/mcp/tokens", async (req, reply) => {
|
||||
try {
|
||||
requireMcpTokenManagementPermission(req)
|
||||
|
||||
if (!req.user?.tenant_id || !req.user?.user_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const body = req.body as {
|
||||
name?: string
|
||||
expiresAt?: string | null
|
||||
}
|
||||
|
||||
const token = createMcpToken()
|
||||
const expiresAt = body?.expiresAt ? new Date(body.expiresAt) : null
|
||||
|
||||
if (expiresAt && Number.isNaN(expiresAt.getTime())) {
|
||||
return reply.code(400).send({ error: "expiresAt must be a valid date" })
|
||||
}
|
||||
|
||||
const [created] = await server.db
|
||||
.insert(m2mApiKeys)
|
||||
.values({
|
||||
tenantId: req.user.tenant_id,
|
||||
userId: req.user.user_id,
|
||||
createdBy: req.user.user_id,
|
||||
name: body?.name?.trim() || "FEDEO MCP Token",
|
||||
keyPrefix: token.slice(0, 20),
|
||||
keyHash: hashApiKey(token),
|
||||
active: true,
|
||||
expiresAt,
|
||||
})
|
||||
.returning({
|
||||
id: m2mApiKeys.id,
|
||||
createdAt: m2mApiKeys.createdAt,
|
||||
name: m2mApiKeys.name,
|
||||
keyPrefix: m2mApiKeys.keyPrefix,
|
||||
active: m2mApiKeys.active,
|
||||
expiresAt: m2mApiKeys.expiresAt,
|
||||
})
|
||||
|
||||
return {
|
||||
token,
|
||||
tokenType: "Bearer",
|
||||
note: "Token wird nur einmal angezeigt. Bitte direkt in Codex oder einem Secret Store hinterlegen.",
|
||||
record: created,
|
||||
}
|
||||
} catch (error) {
|
||||
const statusCode = (error as any)?.statusCode || 500
|
||||
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
server.delete("/mcp/tokens/:id", async (req, reply) => {
|
||||
try {
|
||||
requireMcpTokenManagementPermission(req)
|
||||
|
||||
if (!req.user?.tenant_id || !req.user?.user_id) {
|
||||
return reply.code(400).send({ error: "No tenant selected" })
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string }
|
||||
|
||||
const [existing] = await server.db
|
||||
.select({
|
||||
id: m2mApiKeys.id,
|
||||
keyPrefix: m2mApiKeys.keyPrefix,
|
||||
})
|
||||
.from(m2mApiKeys)
|
||||
.where(and(
|
||||
eq(m2mApiKeys.id, id),
|
||||
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||
))
|
||||
.limit(1)
|
||||
|
||||
if (!existing || !existing.keyPrefix.startsWith("fedeo_mcp_")) {
|
||||
return reply.code(404).send({ error: "MCP token not found" })
|
||||
}
|
||||
|
||||
const [updated] = await server.db
|
||||
.update(m2mApiKeys)
|
||||
.set({
|
||||
active: false,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(m2mApiKeys.id, id),
|
||||
eq(m2mApiKeys.tenantId, req.user.tenant_id)
|
||||
))
|
||||
.returning({
|
||||
id: m2mApiKeys.id,
|
||||
name: m2mApiKeys.name,
|
||||
keyPrefix: m2mApiKeys.keyPrefix,
|
||||
active: m2mApiKeys.active,
|
||||
})
|
||||
|
||||
return { token: updated }
|
||||
} catch (error) {
|
||||
const statusCode = (error as any)?.statusCode || 500
|
||||
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Internal Server Error" })
|
||||
}
|
||||
})
|
||||
|
||||
server.post("/mcp", async (req, reply) => {
|
||||
const body = req.body as JsonRpcRequest | JsonRpcRequest[]
|
||||
const requests = Array.isArray(body) ? body : [body]
|
||||
@@ -141,4 +297,3 @@ export default async function mcpRoutes(server: FastifyInstance) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user