285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { requirePlatformRole } from "@/lib/session";
|
|
import {
|
|
getTenantRequestById,
|
|
updateTenantRequestStatus,
|
|
clearEncryptedSecrets,
|
|
} from "@/lib/db";
|
|
import { createTenant, patchTenantSpec, setTenantAnnotation } from "@/lib/k8s";
|
|
import { sendApprovalEmail, sendResumeApprovalEmail } from "@/lib/email";
|
|
import { decryptSecrets } from "@/lib/crypto";
|
|
import { writePackageSecrets } from "@/lib/openbao";
|
|
import { createRoute as createRelayRoute } from "@/lib/threema-relay";
|
|
import {
|
|
getDefaultSoulMd,
|
|
getDefaultAgentsMd,
|
|
generateToolsMd,
|
|
} from "@/lib/workspace-defaults";
|
|
import { deriveTenantName } from "@/lib/tenant-naming";
|
|
import { safeError } from "@/lib/errors";
|
|
|
|
/**
|
|
* POST /api/admin/requests/[id]/approve
|
|
*
|
|
* Approve a request. Two paths depending on request_type:
|
|
*
|
|
* Provision (the original purpose):
|
|
* 1. Decrypt stored package secrets (if any)
|
|
* 2. Write each package's secrets to OpenBao
|
|
* 3. Null the encrypted_secrets column
|
|
* 4. Build workspace files (SOUL.md, AGENTS.md, TOOLS.md)
|
|
* 5. Create PiecedTenant CR
|
|
* 6. Update request status, notify customer.
|
|
* Supports re-approving a previously rejected request (clears admin notes).
|
|
*
|
|
* Resume (Bug 37a):
|
|
* 1. PATCH spec.suspend=false on the existing PiecedTenant CR.
|
|
* 2. Clear the `pieced.ch/resume-request-pending` annotation so the
|
|
* operator knows the request is settled (and doesn't pause its
|
|
* 60-day TTL forever — though now that the tenant isn't suspended,
|
|
* the timer is moot).
|
|
* 3. Mark request approved, notify customer.
|
|
* No CR creation, no secret materialisation, no workspace files.
|
|
*/
|
|
export async function POST(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ id: string }> }
|
|
) {
|
|
try {
|
|
await requirePlatformRole();
|
|
} catch {
|
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
|
}
|
|
|
|
const { id } = await params;
|
|
const body = await request.json().catch(() => ({}));
|
|
const adminNotes = body.adminNotes as string | undefined;
|
|
|
|
const tenantRequest = await getTenantRequestById(id);
|
|
if (!tenantRequest) {
|
|
return NextResponse.json(
|
|
{ error: "Request not found" },
|
|
{ status: 404 }
|
|
);
|
|
}
|
|
|
|
if (
|
|
tenantRequest.status !== "pending" &&
|
|
tenantRequest.status !== "rejected"
|
|
) {
|
|
return NextResponse.json(
|
|
{ error: `Request is already ${tenantRequest.status}` },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
// Resume request: short path. Just patch the existing tenant, clear
|
|
// the annotation, mark approved.
|
|
if (tenantRequest.requestType === "resume") {
|
|
if (!tenantRequest.tenantName) {
|
|
// Shouldn't happen — resume requests are created with tenant_name
|
|
// set. Defensive 500 if it does.
|
|
return NextResponse.json(
|
|
{ error: "Resume request has no tenant_name" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
try {
|
|
await patchTenantSpec(tenantRequest.tenantName, { suspend: false });
|
|
// Clear the annotation that pauses the operator's 60-day TTL.
|
|
// Best-effort — annotation cleanup is also done by the operator
|
|
// when it sees suspend=false on the next reconcile (it clears
|
|
// status.suspendedAt), but explicitly clearing here keeps the
|
|
// CR clean.
|
|
try {
|
|
await setTenantAnnotation(
|
|
tenantRequest.tenantName,
|
|
"pieced.ch/resume-request-pending",
|
|
null
|
|
);
|
|
} catch (e) {
|
|
console.warn(
|
|
"post-approve annotation clear failed; not blocking",
|
|
e
|
|
);
|
|
}
|
|
|
|
await updateTenantRequestStatus(id, "approved", { adminNotes });
|
|
|
|
await sendResumeApprovalEmail(
|
|
tenantRequest.contactEmail,
|
|
tenantRequest.contactName,
|
|
tenantRequest.companyName
|
|
).catch((e) => console.error("resume approval email failed:", e));
|
|
|
|
return NextResponse.json({
|
|
message: "Resume approved. Tenant is reactivating.",
|
|
tenantName: tenantRequest.tenantName,
|
|
});
|
|
} catch (e: any) {
|
|
console.error("Resume approval failed:", e);
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to approve resume") },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
const isReApproval = tenantRequest.status === "rejected";
|
|
|
|
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
|
|
// Slice 4: for personal accounts the slug is replaced by the literal
|
|
// "p-" prefix so no PII is embedded in the K8s namespace name.
|
|
const tenantName = deriveTenantName(
|
|
tenantRequest.isPersonal ? "personal" : "company",
|
|
tenantRequest.companyName,
|
|
tenantRequest.id
|
|
);
|
|
|
|
try {
|
|
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)
|
|
if (tenantRequest.encryptedSecrets) {
|
|
const secrets = await decryptSecrets(tenantRequest.encryptedSecrets);
|
|
for (const [packageId, pkgSecrets] of Object.entries(secrets)) {
|
|
await writePackageSecrets(
|
|
`tenant-${tenantName}`,
|
|
packageId,
|
|
pkgSecrets
|
|
);
|
|
}
|
|
// Step 2: Null the encrypted column — secrets are now safely in OpenBao
|
|
await clearEncryptedSecrets(id);
|
|
}
|
|
|
|
// Step 3: Build workspace files
|
|
const packages = tenantRequest.packages ?? [];
|
|
const soulMd =
|
|
tenantRequest.soulMd ||
|
|
(await getDefaultSoulMd(tenantRequest.companyName));
|
|
const agentsMd = tenantRequest.agentsMd || (await getDefaultAgentsMd());
|
|
const toolsMd = await generateToolsMd(packages);
|
|
|
|
const workspaceFiles: Record<string, string> = {
|
|
"SOUL.md": soulMd,
|
|
"AGENTS.md": agentsMd,
|
|
"TOOLS.md": toolsMd,
|
|
};
|
|
|
|
// Step 4: Create the PiecedTenant CR.
|
|
// displayName precedence:
|
|
// 1. customer-chosen instance name (Slice 3 multi-tenant)
|
|
// 2. for personal accounts, the contact name (avoids exposing the
|
|
// synthetic "{name} (Personal)" company name in the OpenClaw UI)
|
|
// 3. company name otherwise
|
|
const displayName =
|
|
tenantRequest.instanceName && tenantRequest.instanceName.trim().length > 0
|
|
? tenantRequest.instanceName.trim()
|
|
: tenantRequest.isPersonal
|
|
? tenantRequest.contactName || "Assistant"
|
|
: tenantRequest.companyName;
|
|
|
|
// Phase 9b: split the customer's initial channel-user ids into
|
|
// (a) ids the operator needs in spec.channelUsers (telegram,
|
|
// discord, …) — passed straight into createTenant
|
|
// (b) Threema ids that ALSO need a relay route registered so
|
|
// inbound messages reach this tenant. Threema is in (a)
|
|
// AND (b): spec.channelUsers tells the operator the id is
|
|
// authorized; the relay's route maps inbound traffic from
|
|
// that id to this tenant.
|
|
const initialChannelUsers = tenantRequest.channelUsers ?? {};
|
|
// Strip channels the customer didn't actually enable (defensive
|
|
// — the wizard already filters this, but the row could carry
|
|
// stale data if the customer edited their request post-submit).
|
|
const filteredChannelUsers: Record<string, string[]> = {};
|
|
for (const [channel, ids] of Object.entries(initialChannelUsers)) {
|
|
if (!packages.includes(channel)) continue;
|
|
const cleaned = (ids ?? [])
|
|
.map((s) => (s ?? "").trim())
|
|
.filter((s) => s.length > 0);
|
|
if (cleaned.length > 0) {
|
|
filteredChannelUsers[channel] = cleaned;
|
|
}
|
|
}
|
|
|
|
await createTenant(
|
|
tenantName,
|
|
{
|
|
displayName,
|
|
agentName: tenantRequest.agentName,
|
|
packages,
|
|
workspaceFiles,
|
|
...(Object.keys(filteredChannelUsers).length > 0
|
|
? { channelUsers: filteredChannelUsers }
|
|
: {}),
|
|
},
|
|
{
|
|
"pieced.ch/zitadel-org-id": tenantRequest.zitadelOrgId,
|
|
// Bug 7: stamp the personal flag on the CR so callers (notably
|
|
// the tenant detail page) can hide assignment-related UI
|
|
// without an extra DB join. Slice 4 already tracks this on the
|
|
// request row; the CR label is the same fact at the K8s layer.
|
|
// Legacy tenants approved before this change won't carry the
|
|
// label — operators can backfill with `kubectl label`.
|
|
...(tenantRequest.isPersonal
|
|
? { "pieced.ch/personal": "true" }
|
|
: {}),
|
|
}
|
|
);
|
|
|
|
// Threema: register relay routes for each id the customer
|
|
// entered. Best-effort — a route failure doesn't unwind the
|
|
// tenant creation (admin can retry from the tenant page later).
|
|
// The Threema package itself isn't enabled on the tenant until
|
|
// the customer toggles it from the tenant detail page (which
|
|
// also mints the per-tenant token); the routes here pre-warm
|
|
// the relay so the first toggle works without re-typing the id.
|
|
if (
|
|
packages.includes("threema") &&
|
|
filteredChannelUsers.threema &&
|
|
filteredChannelUsers.threema.length > 0
|
|
) {
|
|
for (const tid of filteredChannelUsers.threema) {
|
|
try {
|
|
const res = await createRelayRoute(tenantName, tid);
|
|
if (!res.ok) {
|
|
console.warn(
|
|
`[approve] Threema route create for tenant=${tenantName} id=${tid} returned not-ok: ${res.message}`
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error(
|
|
`[approve] Threema route create threw for tenant=${tenantName} id=${tid}:`,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 5: Update request status — clear admin notes on re-approval
|
|
const updated = await updateTenantRequestStatus(id, "provisioning", {
|
|
adminNotes: isReApproval ? null : adminNotes,
|
|
tenantName,
|
|
clearAdminNotes: isReApproval,
|
|
});
|
|
|
|
// Step 6: Notify customer
|
|
await sendApprovalEmail(
|
|
tenantRequest.contactEmail,
|
|
tenantRequest.contactName,
|
|
tenantRequest.companyName
|
|
);
|
|
|
|
return NextResponse.json({
|
|
message: "Tenant approved and provisioning started.",
|
|
request: updated,
|
|
tenantName,
|
|
});
|
|
} catch (e: any) {
|
|
console.error("Failed to create tenant:", e);
|
|
return NextResponse.json(
|
|
{ error: safeError(e, "Failed to create tenant") },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|