194 lines
6.1 KiB
TypeScript
194 lines
6.1 KiB
TypeScript
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 }
|
|
);
|
|
}
|
|
}
|