This commit is contained in:
168
src/app/api/tenants/[name]/threema/route.ts
Normal file
168
src/app/api/tenants/[name]/threema/route.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
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/<tenant-{name}>/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/<name> 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
217
src/app/api/tenants/[name]/threema/routes/route.ts
Normal file
217
src/app/api/tenants/[name]/threema/routes/route.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import {
|
||||
createRoute,
|
||||
deleteRoute,
|
||||
isRouteConflictForOtherTenant,
|
||||
isRouteConflictForSameTenant,
|
||||
listRoutes,
|
||||
} from "@/lib/threema-relay";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* Threema route management — keeps three places in sync:
|
||||
*
|
||||
* 1. Relay DB (`routes` table) — source of truth for uniqueness
|
||||
* 2. K8s spec.channelUsers.threema — what the operator sees
|
||||
* 3. Customer UI — derived from (2)
|
||||
*
|
||||
* Add (POST) order: relay first (to claim uniqueness atomically), then
|
||||
* K8s. On K8s failure we compensate by deleting the relay route.
|
||||
*
|
||||
* Remove (DELETE) order: K8s first (UI shows it gone immediately, which
|
||||
* is the customer-facing semantic that matters), then relay. On relay
|
||||
* failure we DO NOT rollback K8s — the customer wanted it gone, and
|
||||
* the relay's stale route will be cleaned up on the next retry (deletes
|
||||
* are idempotent at the relay).
|
||||
*
|
||||
* Read-modify-write race: patchTenantSpec uses K8s merge-patch on
|
||||
* spec.channelUsers.threema, which REPLACES the entire array. We GET
|
||||
* the latest array, mutate it, then PATCH. Two concurrent adds from the
|
||||
* same customer's tabs can lose one of them. Acceptable at pilot scale
|
||||
* (single-digit customers, low concurrency); revisit with SSA + field
|
||||
* managers if it ever bites.
|
||||
*/
|
||||
|
||||
const ROUTE_BODY = z.object({
|
||||
threemaId: z
|
||||
.string()
|
||||
.regex(/^[A-Z0-9]{8}$/, "Threema ID must be 8 uppercase alphanumeric chars (no asterisk)"),
|
||||
});
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
async function loadTenantOrError(name: string, user: Awaited<ReturnType<typeof getSessionUser>>) {
|
||||
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 401 }) };
|
||||
if (!canMutate(user))
|
||||
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant)
|
||||
return { error: NextResponse.json({ error: "Not found" }, { status: 404 }) };
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||
}
|
||||
return { tenant };
|
||||
}
|
||||
|
||||
function currentThreemaIds(tenantSpec: any): string[] {
|
||||
const cu = tenantSpec?.channelUsers ?? {};
|
||||
const ids = cu.threema;
|
||||
return Array.isArray(ids) ? ids.filter((x) => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
// ---- GET ------------------------------------------------------------------
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
const { name } = await params;
|
||||
const loaded = await loadTenantOrError(name, user);
|
||||
if (loaded.error) return loaded.error;
|
||||
|
||||
const res = await listRoutes(name);
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Relay list failed: ${res.message}` },
|
||||
{ status: res.kind === "http" ? 502 : 503 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ routes: res.routes });
|
||||
}
|
||||
|
||||
// ---- POST -----------------------------------------------------------------
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
const { name } = await params;
|
||||
const loaded = await loadTenantOrError(name, user);
|
||||
if (loaded.error) return loaded.error;
|
||||
const tenant = loaded.tenant;
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const parsed = ROUTE_BODY.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid threemaId", details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const threemaId = parsed.data.threemaId;
|
||||
|
||||
// Step 1: claim at the relay. Uniqueness is enforced here.
|
||||
const claim = await createRoute(name, threemaId);
|
||||
if (!claim.ok) {
|
||||
if (isRouteConflictForOtherTenant(claim, name)) {
|
||||
return NextResponse.json(
|
||||
{ error: "This Threema ID is already registered to another tenant" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
if (!isRouteConflictForSameTenant(claim, name)) {
|
||||
// Genuine non-409 failure
|
||||
return NextResponse.json(
|
||||
{ error: `Relay create failed: ${claim.message}` },
|
||||
{ status: claim.kind === "http" ? claim.status : 503 },
|
||||
);
|
||||
}
|
||||
// Idempotent self-claim — continue to step 2 to ensure K8s mirrors it.
|
||||
}
|
||||
|
||||
// Step 2: add to K8s spec.channelUsers.threema (idempotent).
|
||||
const existing = currentThreemaIds(tenant!.spec);
|
||||
if (!existing.includes(threemaId)) {
|
||||
const next = [...existing, threemaId];
|
||||
try {
|
||||
await patchTenantSpec(name, {
|
||||
channelUsers: {
|
||||
...(tenant!.spec?.channelUsers ?? {}),
|
||||
threema: next,
|
||||
} as Record<string, string[]>,
|
||||
});
|
||||
} catch (e) {
|
||||
// Compensate: drop the relay route so we don't leave an orphan.
|
||||
const compensate = await deleteRoute(name, threemaId);
|
||||
if (!compensate.ok) {
|
||||
console.error(
|
||||
`[threema/routes] Compensating route delete failed for ${name}/${threemaId}: ${compensate.message}`,
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, threemaId });
|
||||
}
|
||||
|
||||
// ---- DELETE ---------------------------------------------------------------
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> },
|
||||
) {
|
||||
const user = await getSessionUser();
|
||||
const { name } = await params;
|
||||
const loaded = await loadTenantOrError(name, user);
|
||||
if (loaded.error) return loaded.error;
|
||||
const tenant = loaded.tenant;
|
||||
|
||||
// threemaId arrives as ?threemaId=... since DELETE bodies are uneven across clients.
|
||||
const threemaId = new URL(req.url).searchParams.get("threemaId") ?? "";
|
||||
const parsed = ROUTE_BODY.safeParse({ threemaId });
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid threemaId", details: parsed.error.flatten() },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: drop from K8s (idempotent).
|
||||
const existing = currentThreemaIds(tenant!.spec);
|
||||
if (existing.includes(parsed.data.threemaId)) {
|
||||
const next = existing.filter((id) => id !== parsed.data.threemaId);
|
||||
try {
|
||||
await patchTenantSpec(name, {
|
||||
channelUsers: {
|
||||
...(tenant!.spec?.channelUsers ?? {}),
|
||||
threema: next,
|
||||
} as Record<string, string[]>,
|
||||
});
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `K8s patch failed: ${safeError(e, "patch failed")}` },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: drop at relay (also idempotent — 404 is fine).
|
||||
const dropped = await deleteRoute(name, parsed.data.threemaId);
|
||||
if (!dropped.ok && !(dropped.kind === "http" && dropped.status === 404)) {
|
||||
// K8s is already updated; surface but don't rollback. Next time the
|
||||
// user toggles, both will converge.
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
threemaId: parsed.data.threemaId,
|
||||
warning: `Removed from K8s but relay drop failed: ${dropped.message}`,
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, threemaId: parsed.data.threemaId });
|
||||
}
|
||||
Reference in New Issue
Block a user