From d0de3cb92eded92596f1ff83a60b2afc9e7c9301 Mon Sep 17 00:00:00 2001 From: florianfederspiel Date: Mon, 18 May 2026 17:11:52 +0200 Subject: [PATCH] KI-AGENT: Matrix-Mandanten-Space provisionieren --- .env.example | 1 + backend/src/modules/matrix.service.ts | 238 ++++++++++++++++++++++-- backend/src/routes/communication.ts | 22 +++ backend/src/utils/secrets.ts | 1 + frontend/pages/communication/index.vue | 243 ++++++++++++++++++------- 5 files changed, 418 insertions(+), 87 deletions(-) diff --git a/.env.example b/.env.example index f79fc53..f270545 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,4 @@ MATRIX_DEV_TURN_MAX_PORT=49200 # Backend-Integration gegen den lokalen Matrix-Stack MATRIX_HOMESERVER_URL=http://localhost:8008 MATRIX_REGISTRATION_SHARED_SECRET=copy-from-matrix-dev-synapse-homeserver-yaml +MATRIX_SERVICE_USER_LOCALPART=fedeo_service diff --git a/backend/src/modules/matrix.service.ts b/backend/src/modules/matrix.service.ts index 4784e77..1cd802e 100644 --- a/backend/src/modules/matrix.service.ts +++ b/backend/src/modules/matrix.service.ts @@ -2,7 +2,7 @@ import { createHash, createHmac, randomBytes } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { resolve } from "node:path" import { FastifyInstance } from "fastify" -import { authProfiles, authUsers } from "../../db/schema" +import { authProfiles, authUsers, tenants } from "../../db/schema" import { and, eq } from "drizzle-orm" import { secrets } from "../utils/secrets" @@ -50,6 +50,11 @@ const normalizeMatrixLocalpartSeed = (value: string) => { return normalized || "user" } +const normalizeMatrixAliasSeed = (value: string) => + normalizeMatrixLocalpartSeed(value) + .replace(/[.=]/g, "_") + .replace(/_+/g, "_") + export function matrixService(server: FastifyInstance) { const homeserverUrl = () => trimTrailingSlash( @@ -69,6 +74,16 @@ export function matrixService(server: FastifyInstance) { readLocalDevRegistrationSharedSecret() || "" + const serviceUserLocalpart = () => + process.env.MATRIX_SERVICE_USER_LOCALPART || + secrets.MATRIX_SERVICE_USER_LOCALPART || + "fedeo_service" + + const serviceUserPassword = () => + createHmac("sha256", registrationSharedSecret()) + .update(`${serverName()}:fedeo-service-user`) + .digest("base64url") + const getUserIdentitySeed = async (userId: string, tenantId: number | null) => { const [user] = await server.db .select({ email: authUsers.email }) @@ -99,6 +114,14 @@ export function matrixService(server: FastifyInstance) { const matrixUserIdForUser = async (userId: string, tenantId: number | null) => `@${await matrixLocalpartForUser(userId, tenantId)}:${serverName()}` + const tenantSpaceAliasLocalpart = (tenant: { id: number, short?: string | null, name?: string | null }) => { + const seed = normalizeMatrixAliasSeed(tenant.short || tenant.name || `tenant_${tenant.id}`) + return `fedeo_${seed}_${tenant.id}` + } + + const tenantSpaceAlias = (tenant: { id: number, short?: string | null, name?: string | null }) => + `#${tenantSpaceAliasLocalpart(tenant)}:${serverName()}` + const buildSharedSecretMac = ( nonce: string, username: string, @@ -116,6 +139,69 @@ export function matrixService(server: FastifyInstance) { return hmac.digest("hex") } + const registerWithSharedSecret = async ( + username: string, + password: string, + admin: boolean + ) => { + const nonceResponse = await requestJson<{ nonce: string }>( + `${homeserverUrl()}/_synapse/admin/v1/register` + ) + const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, admin) + + return requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nonce: nonceResponse.nonce, + username, + password, + admin, + mac, + }), + }) + } + + const loginMatrixUser = async (username: string, password: string) => { + return requestJson<{ + access_token: string + user_id: string + }>(`${homeserverUrl()}/_matrix/client/v3/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: username, + }, + password, + }), + }) + } + + const ensureServiceAccessToken = async () => { + if (!registrationSharedSecret()) { + throw Object.assign( + new Error("MATRIX_REGISTRATION_SHARED_SECRET is not configured"), + { statusCode: 503 } + ) + } + + const username = serviceUserLocalpart() + const password = serviceUserPassword() + + try { + await registerWithSharedSecret(username, password, true) + } catch (err: any) { + if (err.errcode !== "M_USER_IN_USE") { + throw err + } + } + + return loginMatrixUser(username, password) + } + const getCurrentUserDisplayName = async (userId: string, tenantId: number | null) => { if (tenantId) { const [profile] = await server.db @@ -167,6 +253,16 @@ export function matrixService(server: FastifyInstance) { return body as T } + const requestMatrixJson = async (path: string, accessToken: string, init?: RequestInit): Promise => { + return requestJson(`${homeserverUrl()}${path}`, { + ...init, + headers: { + ...(init?.headers || {}), + Authorization: `Bearer ${accessToken}`, + }, + }) + } + const getStatus = async () => { const configured = Boolean(homeserverUrl() && serverName()) @@ -218,24 +314,8 @@ export function matrixService(server: FastifyInstance) { const password = randomBytes(32).toString("base64url") const displayName = await getCurrentUserDisplayName(userId, tenantId) - const nonceResponse = await requestJson<{ nonce: string }>( - `${homeserverUrl()}/_synapse/admin/v1/register` - ) - - const mac = buildSharedSecretMac(nonceResponse.nonce, username, password, false) - try { - await requestJson(`${homeserverUrl()}/_synapse/admin/v1/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - nonce: nonceResponse.nonce, - username, - password, - admin: false, - mac, - }), - }) + await registerWithSharedSecret(username, password, false) return { matrixUserId, @@ -259,10 +339,132 @@ export function matrixService(server: FastifyInstance) { } } + const getCurrentTenant = async (tenantId: number | null) => { + if (!tenantId) { + throw Object.assign( + new Error("No active tenant selected"), + { statusCode: 400 } + ) + } + + const [tenant] = await server.db + .select({ + id: tenants.id, + name: tenants.name, + short: tenants.short, + }) + .from(tenants) + .where(eq(tenants.id, tenantId)) + .limit(1) + + if (!tenant) { + throw Object.assign( + new Error("Tenant not found"), + { statusCode: 404 } + ) + } + + return tenant + } + + const getTenantSpaceStatus = async (tenantId: number | null) => { + const tenant = await getCurrentTenant(tenantId) + const alias = tenantSpaceAlias(tenant) + + try { + const directoryEntry = await requestJson<{ + room_id: string + servers: string[] + }>(`${homeserverUrl()}/_matrix/client/v3/directory/room/${encodeURIComponent(alias)}`) + + return { + tenantId: tenant.id, + tenantName: tenant.name, + alias, + exists: true, + roomId: directoryEntry.room_id, + servers: directoryEntry.servers, + } + } catch (err: any) { + if (err.statusCode === 404 || err.errcode === "M_NOT_FOUND") { + return { + tenantId: tenant.id, + tenantName: tenant.name, + alias, + exists: false, + roomId: null, + servers: [], + } + } + + throw err + } + } + + const provisionCurrentTenantSpace = async (userId: string, tenantId: number | null) => { + const tenant = await getCurrentTenant(tenantId) + const existing = await getTenantSpaceStatus(tenant.id) + const userAccount = await provisionCurrentUser(userId, tenant.id) + + if (existing.exists) { + return { + ...existing, + created: false, + alreadyExisted: true, + invitedUserId: userAccount.matrixUserId, + } + } + + const serviceLogin = await ensureServiceAccessToken() + const aliasLocalpart = tenantSpaceAliasLocalpart(tenant) + const createdRoom = await requestMatrixJson<{ room_id: string }>( + "/_matrix/client/v3/createRoom", + serviceLogin.access_token, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + creation_content: { + type: "m.space", + }, + name: `FEDEO · ${tenant.name}`, + topic: `Kommunikationsbereich für ${tenant.name}`, + preset: "private_chat", + visibility: "private", + room_alias_name: aliasLocalpart, + invite: [userAccount.matrixUserId], + initial_state: [ + { + type: "m.room.history_visibility", + state_key: "", + content: { + history_visibility: "invited", + }, + }, + ], + }), + } + ) + + return { + tenantId: tenant.id, + tenantName: tenant.name, + alias: tenantSpaceAlias(tenant), + exists: true, + created: true, + alreadyExisted: false, + roomId: createdRoom.room_id, + invitedUserId: userAccount.matrixUserId, + serviceUserId: serviceLogin.user_id, + } + } + return { getStatus, matrixUserIdForUser, getCurrentUserDisplayName, provisionCurrentUser, + getTenantSpaceStatus, + provisionCurrentTenantSpace, } } diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index 27c1ede..4be081c 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -27,4 +27,26 @@ export default async function communicationRoutes(server: FastifyInstance) { .send({ error: err.message || "Matrix provisioning failed" }) } }) + + server.get("/communication/matrix/tenant-space", async (req, reply) => { + try { + return await matrix.getTenantSpaceStatus(req.user.tenant_id) + } catch (err: any) { + req.log.error(err) + return reply + .code(err.statusCode || 500) + .send({ error: err.message || "Matrix tenant space status failed" }) + } + }) + + server.post("/communication/matrix/tenant-space/provision", async (req, reply) => { + try { + return await matrix.provisionCurrentTenantSpace(req.user.user_id, req.user.tenant_id) + } catch (err: any) { + req.log.error(err) + return reply + .code(err.statusCode || 500) + .send({ error: err.message || "Matrix tenant space provisioning failed" }) + } + }) } diff --git a/backend/src/utils/secrets.ts b/backend/src/utils/secrets.ts index 80b88a2..237a05e 100644 --- a/backend/src/utils/secrets.ts +++ b/backend/src/utils/secrets.ts @@ -41,6 +41,7 @@ export let secrets = { MATRIX_HOMESERVER_URL?: string MATRIX_SERVER_NAME?: string MATRIX_REGISTRATION_SHARED_SECRET?: string + MATRIX_SERVICE_USER_LOCALPART?: string } export async function loadSecrets () { diff --git a/frontend/pages/communication/index.vue b/frontend/pages/communication/index.vue index 52d7e16..453430e 100644 --- a/frontend/pages/communication/index.vue +++ b/frontend/pages/communication/index.vue @@ -4,9 +4,12 @@ const { $api } = useNuxtApp() const status = ref(null) const identity = ref(null) +const tenantSpace = ref(null) const provisionResult = ref(null) +const tenantSpaceProvisionResult = ref(null) const loading = ref(false) const provisioning = ref(false) +const tenantSpaceProvisioning = ref(false) const lastUpdated = ref(null) const statusItems = computed(() => [ @@ -45,13 +48,15 @@ const statusItems = computed(() => [ const loadMatrixInfo = async () => { loading.value = true try { - const [statusRes, identityRes] = await Promise.all([ + const [statusRes, identityRes, tenantSpaceRes] = await Promise.all([ $api("/api/communication/matrix/status"), - $api("/api/communication/matrix/me") + $api("/api/communication/matrix/me"), + $api("/api/communication/matrix/tenant-space") ]) status.value = statusRes identity.value = identityRes + tenantSpace.value = tenantSpaceRes lastUpdated.value = new Date() } catch (error) { toast.add({ @@ -91,6 +96,37 @@ const provisionMatrixAccount = async () => { } } +const provisionTenantSpace = async () => { + tenantSpaceProvisioning.value = true + try { + const res = await $api("/api/communication/matrix/tenant-space/provision", { + method: "POST" + }) + + tenantSpaceProvisionResult.value = res + tenantSpace.value = { + tenantId: res.tenantId, + tenantName: res.tenantName, + alias: res.alias, + exists: true, + roomId: res.roomId, + servers: res.servers || [] + } + + toast.add({ + title: res.alreadyExisted ? "Mandanten-Space ist bereits vorhanden" : "Mandanten-Space erstellt", + color: "success" + }) + } catch (error) { + toast.add({ + title: "Mandanten-Space konnte nicht erstellt werden", + color: "error" + }) + } finally { + tenantSpaceProvisioning.value = false + } +} + const formatDateTime = (value) => { if (!value) return "-" @@ -156,80 +192,149 @@ onMounted(loadMatrixInfo)
- - +
+ + + +
+
+
+

+ Matrix-ID +

+

+ {{ identity?.matrixUserId || "-" }} +

+
+
+

+ Anzeigename +

+

+ {{ identity?.displayName || "-" }} +

+
+
+ + + + + + +
+ + +
+ + + + +
+
+
+

+ Alias +

+

+ {{ tenantSpace?.alias || "-" }} +

+
+
+

+ Status +

+ + {{ tenantSpace?.exists ? "Vorhanden" : "Noch nicht erstellt" }} + +
+
-
-

- Matrix-ID + Raum-ID

- {{ identity?.matrixUserId || "-" }} + {{ tenantSpace?.roomId || "-" }}

-
-

- Anzeigename -

-

- {{ identity?.displayName || "-" }} -

+ + +
+ + + +