169 lines
5.6 KiB
TypeScript
169 lines
5.6 KiB
TypeScript
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 },
|
|
);
|
|
}
|
|
}
|