import { NextRequest, NextResponse } from "next/server"; import { getSessionUser, canMutate } from "@/lib/session"; import { canUserSeeTenant } from "@/lib/visibility"; import { getTenant } from "@/lib/k8s"; import { listAssignmentsForTenant, addTenantAssignment, } from "@/lib/db"; import { getOrgMembers } from "@/lib/team"; import { safeError } from "@/lib/errors"; import { z } from "zod"; const assignSchema = z.object({ userId: z.string().min(1).max(200), }); /** * GET /api/tenants/[name]/assignments * * Returns the list of users assigned to a tenant, joined with their * ZITADEL profile (display name, email, role) so the UI can render * a useful list without an extra round-trip. * * Visibility: any caller who can see the tenant can see its * assignments. This includes user-role members who are themselves * assigned — they see their fellow assignees, which is intentional * (so they know who else has access). */ export async function GET( _req: NextRequest, { params }: { params: Promise<{ name: string }> } ) { const user = await getSessionUser(); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } const { name } = await params; const tenant = await getTenant(name); if (!tenant) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } if (!(await canUserSeeTenant(user, tenant))) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } try { const orgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"]; const [rows, members] = await Promise.all([ listAssignmentsForTenant(name), orgId ? getOrgMembers(orgId) : Promise.resolve([]), ]); const memberById = new Map(members.map((m) => [m.userId, m])); // Enrich assignments with member metadata. If the member can't be // found in ZITADEL (stale row, e.g. user was removed from the org // outside the portal), surface the orphan with a placeholder name // so admins can clean it up. const assignments = rows.map((r) => { const m = memberById.get(r.zitadelUserId); return { userId: r.zitadelUserId, displayName: m?.displayName ?? "(removed user)", email: m?.email ?? "", roles: m?.roles ?? [], assignedAt: r.assignedAt, assignedBy: r.assignedBy, orphan: !m, }; }); return NextResponse.json({ assignments }); } catch (e: any) { console.error("Failed to list tenant assignments:", e); return NextResponse.json( { error: safeError(e, "Failed to list assignments") }, { status: 500 } ); } } /** * POST /api/tenants/[name]/assignments * * Body: { userId } * * Assign a user to a tenant. Owner+platform only. The target user must * already be a member of the tenant's org (we verify via the team list) * — to add a brand-new user, the owner first invites them via * POST /api/team/invite, then assigns them here. * * Idempotent: re-assigning is a no-op (DB INSERT ... ON CONFLICT DO * NOTHING). The original `assignedAt`/`assignedBy` are preserved. * * Owners technically don't need to be assigned (they see all of their * org's tenants anyway) but we don't reject the operation — just lets * future bookkeeping work consistently. */ 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; const tenant = await getTenant(name); if (!tenant) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } // Customer owners can only assign within their own org. Platform // users can assign anywhere (rare, but consistent with admin scope). const tenantOrgId = tenant.metadata.labels?.["pieced.ch/zitadel-org-id"]; if (!user.isPlatform && tenantOrgId !== user.orgId) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } if (!tenantOrgId) { return NextResponse.json( { error: "Tenant is missing the org-id label; cannot assign." }, { status: 500 } ); } // Bug 7 server-side counterpart: personal tenants are sole-owner // by definition. Reject any assignment attempt — this matches the // hidden panel on the detail page and stops a determined client // (or platform user with a legacy unlabeled personal tenant) from // creating spurious rows. if ( tenant.metadata.labels?.["pieced.ch/personal"] === "true" || (!user.isPlatform && user.isPersonal) ) { return NextResponse.json( { error: "Personal tenants do not support additional assignments.", code: "personal_tenant", }, { status: 403 } ); } const body = await req.json().catch(() => null); const parsed = assignSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } // Verify the target user is actually a member of the tenant's org. // This is the audit boundary — without it, an owner could grant // access to arbitrary user IDs they made up. try { const members = await getOrgMembers(tenantOrgId); const target = members.find((m) => m.userId === parsed.data.userId); if (!target) { return NextResponse.json( { error: "Target user is not a member of this organization. Invite them first.", code: "user_not_in_org", }, { status: 400 } ); } await addTenantAssignment({ tenantName: name, orgId: tenantOrgId, userId: parsed.data.userId, assignedBy: user.id, }); return NextResponse.json( { message: "User assigned.", userId: parsed.data.userId }, { status: 201 } ); } catch (e: any) { console.error("Failed to add tenant assignment:", e); return NextResponse.json( { error: safeError(e, "Failed to assign user") }, { status: 500 } ); } }