import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { getTenant } from "@/lib/k8s"; import { writePackageSecrets, deletePackageSecrets, } from "@/lib/openbao"; import { mintToken, revokeToken } from "@/lib/threema-relay"; import { safeError } from "@/lib/errors"; /** * Threema package provisioning — special-cased because the credentials * are platform-issued (relay mints them), not customer-supplied. * * POST /api/tenants/:name/threema * - Mints a per-tenant bearer + HMAC secret from the central relay. * - Writes both to OpenBao under * secret/data/tenants//threema-relay so the * operator's ExternalSecret can sync them into the tenant * namespace alongside other channel secrets. * - Returns 200 on success. The caller (PackageCard) then PATCHes * tenant.spec.packages to add "threema". * * DELETE /api/tenants/:name/threema * - Revokes the per-tenant token at the relay (cascades to all * routes — the relay's tokens.deleteToken also deletes routes). * - Deletes the OpenBao secret so the ExternalSecret/operator can * converge cleanly. * - Returns 200 on success even if no token existed (idempotent). * * Failure semantics * ----------------- * On POST: if minting succeeds but the OpenBao write fails, we attempt * to revoke the just-minted token before returning the error. That way * the relay doesn't keep an orphan token row that nothing can use. * Best-effort cleanup; if the revoke also fails, the relay admin can * use DELETE /admin/tokens/ manually. * * On DELETE: we revoke FIRST, then delete OpenBao. If revoke fails we * return the error and stop — leaving OpenBao alone means the pod's * still-mounted secret keeps working in the brief window between * "customer hits disable" and "operator reconciles spec without threema", * which is more graceful than yanking the secret out from under a * running pod. */ const VAULT_SUFFIX = "threema-relay"; export async function POST( _req: NextRequest, { params }: { params: Promise<{ name: string }> }, ) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!canMutate(user)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { name } = await params; try { const tenant = await getTenant(name); if (!tenant) return NextResponse.json({ error: "Not found" }, { status: 404 }); if ( !user.isPlatform && tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const minted = await mintToken(name); if (!minted.ok) { return NextResponse.json( { error: `Relay mint failed: ${minted.message}` }, { status: minted.kind === "http" ? 502 : 503 }, ); } try { await writePackageSecrets(`tenant-${name}`, VAULT_SUFFIX, { token: minted.token, "hmac-secret": minted.hmacSecret, }); } catch (e) { // Compensate: revoke the just-minted token so the relay doesn't // hold an orphan. Best-effort — log and continue surfacing the // original error. const revoke = await revokeToken(name); if (!revoke.ok) { console.error( `[threema/provision] Compensating revoke failed for ${name}: ${revoke.message}`, ); } return NextResponse.json( { error: `OpenBao write failed: ${safeError(e, "secret store unavailable")}` }, { status: 503 }, ); } return NextResponse.json({ ok: true }); } catch (e) { console.error("[threema/provision]", e); return NextResponse.json( { error: safeError(e, "Provisioning failed") }, { status: 500 }, ); } } export async function DELETE( _req: NextRequest, { params }: { params: Promise<{ name: string }> }, ) { const user = await getSessionUser(); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!canMutate(user)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const { name } = await params; try { const tenant = await getTenant(name); if (!tenant) return NextResponse.json({ error: "Not found" }, { status: 404 }); if ( !user.isPlatform && tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId ) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } const revoke = await revokeToken(name); // 404 from relay = nothing to revoke = idempotent success. if (!revoke.ok && !(revoke.kind === "http" && revoke.status === 404)) { return NextResponse.json( { error: `Relay revoke failed: ${revoke.message}` }, { status: revoke.kind === "http" ? 502 : 503 }, ); } // Delete the OpenBao secret. Idempotent — deletePackageSecrets // tolerates 404. try { await deletePackageSecrets(`tenant-${name}`, VAULT_SUFFIX); } catch (e) { // Already revoked at the relay — surface the openbao failure // but keep the partial-success state visible. return NextResponse.json( { error: `Token revoked, but OpenBao delete failed: ${safeError(e, "secret store unavailable")}`, partial: true, }, { status: 503 }, ); } return NextResponse.json({ ok: true, deletedRoutes: revoke.ok ? revoke.deletedRoutes : 0, }); } catch (e) { console.error("[threema/deprovision]", e); return NextResponse.json( { error: safeError(e, "Deprovisioning failed") }, { status: 500 }, ); } }