Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 22fd5fb2cc |
120
scripts/verify-visibility.mjs
Normal file
120
scripts/verify-visibility.mjs
Normal file
@@ -0,0 +1,120 @@
|
||||
// Standalone JS port of `lib/visibility.ts` for offline verification.
|
||||
// Mirrors the synchronous decision logic — DB call (assignments) is
|
||||
// faked as an array param.
|
||||
|
||||
function scopeFor(user) {
|
||||
if (user.isPlatform) return "all";
|
||||
if (user.roles.includes("owner")) return "org";
|
||||
return "assigned";
|
||||
}
|
||||
|
||||
function listVisibleTenants(user, all, assignments = []) {
|
||||
const scope = scopeFor(user);
|
||||
if (scope === "all") return all;
|
||||
|
||||
const orgScoped = all.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
if (scope === "org") return orgScoped;
|
||||
|
||||
const allowed = new Set(assignments);
|
||||
return orgScoped.filter((t) => allowed.has(t.metadata.name));
|
||||
}
|
||||
|
||||
function canUserSeeTenant(user, tenant, assignments = []) {
|
||||
const scope = scopeFor(user);
|
||||
if (scope === "all") return true;
|
||||
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
|
||||
return false;
|
||||
}
|
||||
if (scope === "org") return true;
|
||||
return assignments.includes(tenant.metadata.name);
|
||||
}
|
||||
|
||||
function canSeeInflightRequests(user) {
|
||||
return scopeFor(user) !== "assigned";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const platformAdmin = { isPlatform: true, roles: ["platform_admin"], orgId: "platform-org", id: "u-admin" };
|
||||
const owner = { isPlatform: false, roles: ["owner"], orgId: "org-acme", id: "u-owner" };
|
||||
const userOnly = { isPlatform: false, roles: ["user"], orgId: "org-acme", id: "u-alice" };
|
||||
const noRoles = { isPlatform: false, roles: [], orgId: "org-acme", id: "u-bob" };
|
||||
|
||||
const tenantA = { metadata: { name: "acme-prod-12345678", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
|
||||
const tenantB = { metadata: { name: "acme-dev-87654321", labels: { "pieced.ch/zitadel-org-id": "org-acme" } } };
|
||||
const tenantC = { metadata: { name: "other-corp-aaaa", labels: { "pieced.ch/zitadel-org-id": "org-other" } } };
|
||||
|
||||
const allTenants = [tenantA, tenantB, tenantC];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listVisibleTenants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const listCases = [
|
||||
{ user: platformAdmin, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321", "other-corp-aaaa"], note: "platform sees all" },
|
||||
{ user: owner, assignments: [], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner sees all org tenants" },
|
||||
{ user: owner, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "owner ignores assignment table even if rows exist" },
|
||||
{ user: userOnly, assignments: [], expected: [], note: "user with no assignments sees nothing" },
|
||||
{ user: userOnly, assignments: ["acme-prod-12345678"], expected: ["acme-prod-12345678"], note: "user sees only assigned tenants" },
|
||||
{ user: userOnly, assignments: ["acme-prod-12345678", "acme-dev-87654321"], expected: ["acme-prod-12345678", "acme-dev-87654321"], note: "user sees multiple assigned tenants" },
|
||||
{ user: userOnly, assignments: ["other-corp-aaaa"], expected: [], note: "stale assignment to other-org tenant doesn't leak" },
|
||||
{ user: noRoles, assignments: [], expected: [], note: "no roles is treated as user-scope (empty)" },
|
||||
];
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
||||
console.log("--- listVisibleTenants ---");
|
||||
for (const c of listCases) {
|
||||
const got = listVisibleTenants(c.user, allTenants, c.assignments).map((t) => t.metadata.name);
|
||||
const ok = JSON.stringify(got) === JSON.stringify(c.expected);
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${JSON.stringify(got)} want=${JSON.stringify(c.expected)} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canUserSeeTenant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n--- canUserSeeTenant ---");
|
||||
const seeCases = [
|
||||
{ user: platformAdmin, tenant: tenantA, assignments: [], expected: true, note: "platform sees same-cluster tenant" },
|
||||
{ user: platformAdmin, tenant: tenantC, assignments: [], expected: true, note: "platform sees other-org tenant" },
|
||||
{ user: owner, tenant: tenantA, assignments: [], expected: true, note: "owner sees own-org tenant" },
|
||||
{ user: owner, tenant: tenantC, assignments: [], expected: false, note: "owner does NOT see other-org tenant" },
|
||||
{ user: userOnly, tenant: tenantA, assignments: ["acme-prod-12345678"], expected: true, note: "user sees assigned tenant" },
|
||||
{ user: userOnly, tenant: tenantA, assignments: [], expected: false, note: "user does NOT see un-assigned own-org tenant" },
|
||||
{ user: userOnly, tenant: tenantC, assignments: ["other-corp-aaaa"], expected: false, note: "user does NOT see other-org tenant even with stale assignment" },
|
||||
];
|
||||
|
||||
for (const c of seeCases) {
|
||||
const got = canUserSeeTenant(c.user, c.tenant, c.assignments);
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// canSeeInflightRequests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
console.log("\n--- canSeeInflightRequests ---");
|
||||
const requestCases = [
|
||||
{ user: platformAdmin, expected: true, note: "platform sees in-flight" },
|
||||
{ user: owner, expected: true, note: "owner sees in-flight" },
|
||||
{ user: userOnly, expected: false, note: "user-role does NOT see in-flight" },
|
||||
{ user: noRoles, expected: false, note: "no-roles does NOT see in-flight" },
|
||||
];
|
||||
|
||||
for (const c of requestCases) {
|
||||
const got = canSeeInflightRequests(c.user);
|
||||
const ok = got === c.expected;
|
||||
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${c.expected} [${c.note}]`);
|
||||
if (ok) pass++; else fail++;
|
||||
}
|
||||
|
||||
console.log(`\n${pass} pass, ${fail} fail`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -3,6 +3,11 @@ import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listActiveTenantRequestsByOrgId } from "@/lib/db";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canSeeInflightRequests,
|
||||
isUserScoped,
|
||||
} from "@/lib/visibility";
|
||||
import { Card, CardHeader } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { OnboardingFlow } from "@/components/onboarding/onboarding-flow";
|
||||
@@ -134,19 +139,40 @@ export default async function DashboardPage() {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Customer view (Slice 3 multi-tenant)
|
||||
// Customer view (Slice 3 multi-tenant + Slice 6 visibility scoping)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
const orgTenants = allTenants.filter(
|
||||
// Slice 6: orgTenants becomes "visible tenants for this user". For an
|
||||
// owner that's all of the org's tenants; for a `user`-role member
|
||||
// it's only the tenants they've been assigned to via
|
||||
// tenant_user_assignments. The dashboard renders fewer cards in the
|
||||
// user-role case but otherwise uses the same template.
|
||||
const orgTenants = await listVisibleTenants(user, allTenants);
|
||||
|
||||
// For the "no instances yet" empty state, we want to know whether
|
||||
// this user is being scoped down. A `user`-role with 0 visible
|
||||
// tenants gets a different message than an owner with 0 tenants
|
||||
// (the user might just need an assignment; the owner needs to
|
||||
// create one).
|
||||
const userScoped = isUserScoped(user);
|
||||
|
||||
// Pending/in-flight requests are only shown to roles that can act on
|
||||
// them. `user`-role customers see no request cards.
|
||||
const orgRequests = canSeeInflightRequests(user)
|
||||
? await listActiveTenantRequestsByOrgId(user.orgId)
|
||||
: [];
|
||||
|
||||
// Pending requests that don't yet have a tenant CR. Once the CR
|
||||
// exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it. We compare against
|
||||
// *all* org tenants here (not just visible ones) — otherwise a
|
||||
// request whose tenant is invisible to the caller would erroneously
|
||||
// show as in-flight.
|
||||
const orgScopedTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const orgRequests = await listActiveTenantRequestsByOrgId(user.orgId);
|
||||
|
||||
// Pending/in-flight requests that don't yet have a tenant CR. Once the
|
||||
// CR exists, the tenant card carries the live phase, so a separate
|
||||
// "request" card would just duplicate it.
|
||||
const inflightRequests = orgRequests.filter(
|
||||
(r) => !r.tenantName || !orgTenants.some((t) => t.metadata.name === r.tenantName)
|
||||
(r) => !r.tenantName || !orgScopedTenants.some((t) => t.metadata.name === r.tenantName)
|
||||
);
|
||||
|
||||
// Slice 5: only owners (and platform users, who'd typically be using
|
||||
@@ -155,14 +181,56 @@ export default async function DashboardPage() {
|
||||
// they need to ask an owner.
|
||||
const canCreate = canMutate(user);
|
||||
|
||||
// First-time user: empty company. Show the onboarding wizard inline.
|
||||
// Note: the registering user is always granted `owner` on their new
|
||||
// org by registerCustomer, so this branch is only reachable by an
|
||||
// owner — no role check needed here. But a customer-side `user`
|
||||
// promoted into a fresh empty org (Slice 7 invites) would also land
|
||||
// here without permission to submit. Belt-and-braces gate.
|
||||
// First-time / no-visibility branch.
|
||||
//
|
||||
// Three sub-cases:
|
||||
// 1. owner / platform with 0 tenants and 0 requests → show wizard.
|
||||
// 2. owner / platform with 0 visibility but the org HAS tenants →
|
||||
// shouldn't happen (owners see all org tenants). Defensive
|
||||
// fall-through to the wizard.
|
||||
// 3. user-role with 0 visible tenants → show "ask your owner"
|
||||
// message, with copy distinguishing whether the org has any
|
||||
// tenants at all.
|
||||
if (orgTenants.length === 0 && inflightRequests.length === 0) {
|
||||
if (userScoped) {
|
||||
// Slice 6 empty state for `user` role. The org might or might
|
||||
// not have tenants — either way this user has none assigned.
|
||||
// The two messages are subtly different: "no instances exist"
|
||||
// means owner needs to create one; "you're not assigned" means
|
||||
// owner needs to grant access.
|
||||
const orgHasTenants = orgScopedTenants.length > 0;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
<h1 className="font-display text-2xl font-semibold accent-rule mb-2">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-text-secondary text-sm mt-4">
|
||||
{t("welcome", { name: user.name || user.email })}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="animate-in animate-in-delay-1">
|
||||
<div className="text-center py-6">
|
||||
<h2 className="font-display text-base font-semibold text-text-primary mb-2">
|
||||
{orgHasTenants
|
||||
? t("noAssignmentsTitle")
|
||||
: t("noInstancesYetTitle")}
|
||||
</h2>
|
||||
<p className="text-sm text-text-secondary max-w-sm mx-auto">
|
||||
{orgHasTenants
|
||||
? t("noAssignmentsDescription")
|
||||
: t("noInstancesYetDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canCreate) {
|
||||
// Belt-and-braces: any role that's neither owner-with-create nor
|
||||
// user-scope ends up here (e.g. weird cases like a session with
|
||||
// no roles at all). Same generic message as before.
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8 animate-in">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { getTranslations, getFormatter } from "next-intl/server";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { getTenant } from "@/lib/k8s";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { StatusBadge } from "@/components/ui/status-badge";
|
||||
import { UsageDisplay } from "@/components/dashboard/usage-display";
|
||||
import { PackageList } from "@/components/packages/package-list";
|
||||
@@ -26,11 +27,10 @@ export default async function TenantDetailPage({
|
||||
const tenant = await getTenant(name);
|
||||
if (!tenant) notFound();
|
||||
|
||||
// Scope check
|
||||
if (
|
||||
!user.isPlatform &&
|
||||
tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId
|
||||
) {
|
||||
// Slice 6: visibility check encompasses org membership AND, for
|
||||
// user-role members, the tenant_user_assignments check. notFound()
|
||||
// (404) rather than redirect/403 to avoid leaking tenant existence.
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePlatformRole } from "@/lib/session";
|
||||
import { getTenant, deleteTenant } from "@/lib/k8s";
|
||||
import { markTenantRequestDeletedByTenantName } from "@/lib/db";
|
||||
import {
|
||||
markTenantRequestDeletedByTenantName,
|
||||
removeAllAssignmentsForTenant,
|
||||
} from "@/lib/db";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
/**
|
||||
* POST /api/admin/tenants/[name]/delete
|
||||
* Delete a PiecedTenant CR. The operator handles cleanup
|
||||
* (namespace, vault, litellm team, etc.).
|
||||
*
|
||||
* Slice 6: also cascades the tenant_user_assignments rows so a
|
||||
* future tenant with the same name (won't happen given UUID-suffix
|
||||
* naming, but defense in depth) doesn't inherit stale assignments.
|
||||
*
|
||||
* Also marks the associated tenant_request as "deleted" so the
|
||||
* customer can re-submit the onboarding wizard.
|
||||
*/
|
||||
@@ -31,10 +39,14 @@ export async function POST(
|
||||
try {
|
||||
await deleteTenant(name);
|
||||
|
||||
// Mark the associated tenant_request as "deleted" so the customer
|
||||
// sees the wizard again instead of a stale "active" status
|
||||
// Best-effort DB cleanups. Both errors are logged but not surfaced —
|
||||
// the K8s deletion has already started, and the row state is just
|
||||
// for portal display.
|
||||
await markTenantRequestDeletedByTenantName(name).catch((e) =>
|
||||
console.error("Failed to update tenant request after delete:", e)
|
||||
console.error("Failed to mark tenant request deleted:", e)
|
||||
);
|
||||
await removeAllAssignmentsForTenant(name).catch((e) =>
|
||||
console.error("Failed to clean up tenant assignments:", e)
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
getMostRecentApprovedRequestForOrg,
|
||||
} from "@/lib/db";
|
||||
import { getTenant, listTenants } from "@/lib/k8s";
|
||||
import {
|
||||
listVisibleTenants,
|
||||
canUserSeeTenant,
|
||||
canSeeInflightRequests,
|
||||
} from "@/lib/visibility";
|
||||
import { sendAdminNotificationEmail } from "@/lib/email";
|
||||
import { encryptSecrets } from "@/lib/crypto";
|
||||
import { isPersonalOrgName } from "@/lib/personal-org";
|
||||
@@ -106,10 +111,24 @@ export async function GET(req: NextRequest) {
|
||||
if (!user.isPlatform && tr.zitadelOrgId !== user.orgId) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
// Slice 6: a `user`-role customer doesn't see in-flight requests
|
||||
// even within their own org — they can't act on them and showing
|
||||
// the row would be a permanent "pending" state with no exit. Owner
|
||||
// and platform skip this gate.
|
||||
if (!canSeeInflightRequests(user)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
let tenant: PiecedTenant | null = null;
|
||||
if (tr.tenantName) {
|
||||
tenant = (await getTenant(tr.tenantName)) ?? null;
|
||||
// If a request is already linked to a tenant CR and the caller
|
||||
// can't see that tenant (assignment scope), don't expose it via
|
||||
// the request endpoint either. canSeeInflightRequests above
|
||||
// already shortcuts this for `user`-role, but defense in depth.
|
||||
if (tenant && !(await canUserSeeTenant(user, tenant))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
return NextResponse.json({
|
||||
request: publicRequestShape(tr),
|
||||
@@ -117,19 +136,21 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
// List view: requests + tenants for this org
|
||||
// List view: requests + tenants for this org, filtered by visibility.
|
||||
// For owner/platform, this returns the same data as pre-Slice-6.
|
||||
// For user-role, requests is forced to [] and tenants is narrowed to
|
||||
// assignments.
|
||||
const [requests, allTenants] = await Promise.all([
|
||||
listActiveTenantRequestsByOrgId(user.orgId),
|
||||
listTenants(),
|
||||
]);
|
||||
|
||||
const orgTenants = allTenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const visibleTenants = await listVisibleTenants(user, allTenants);
|
||||
const visibleRequests = canSeeInflightRequests(user) ? requests : [];
|
||||
|
||||
return NextResponse.json({
|
||||
requests: requests.map(publicRequestShape),
|
||||
tenants: orgTenants.map(publicTenantShape),
|
||||
requests: visibleRequests.map(publicRequestShape),
|
||||
tenants: visibleTenants.map(publicTenantShape),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser, canMutate } from "@/lib/session";
|
||||
import { canUserSeeTenant } from "@/lib/visibility";
|
||||
import { getTenant, patchTenantSpec } from "@/lib/k8s";
|
||||
import { getPackageDef } from "@/lib/packages";
|
||||
import { safeError } from "@/lib/errors";
|
||||
@@ -22,11 +23,11 @@ export async function GET(
|
||||
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 });
|
||||
// Slice 6: visibility now includes assignment-table check for
|
||||
// user-role members. We return 404 (not 403) to avoid leaking
|
||||
// tenant existence — same as cross-org reads.
|
||||
if (!(await canUserSeeTenant(user, tenant))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(tenant);
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listVisibleTenants } from "@/lib/visibility";
|
||||
|
||||
export async function GET() {
|
||||
const user = await getSessionUser();
|
||||
if (!user)
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tenants = await listTenants();
|
||||
|
||||
if (user.isPlatform) {
|
||||
return NextResponse.json(tenants);
|
||||
}
|
||||
|
||||
// Customers see only their own tenant
|
||||
const own = tenants.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
return NextResponse.json(own);
|
||||
const all = await listTenants();
|
||||
const visible = await listVisibleTenants(user, all);
|
||||
return NextResponse.json(visible);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { listTenants } from "@/lib/k8s";
|
||||
import { listVisibleTenants } from "@/lib/visibility";
|
||||
import { getTeamInfo, getTeamSpendLogsV2 } from "@/lib/litellm";
|
||||
import { safeError } from "@/lib/errors";
|
||||
|
||||
@@ -36,12 +37,17 @@ export async function GET(req: NextRequest) {
|
||||
keyAlias = req.nextUrl.searchParams.get("keyAlias") ?? null;
|
||||
}
|
||||
|
||||
// For customers (or admins without explicit params): resolve from their tenant.
|
||||
// For customers (or admins without explicit params): resolve from
|
||||
// the user's *visible* tenants. With Slice 6, a `user`-role member
|
||||
// can only see usage for tenants they're assigned to — a non-assigned
|
||||
// user defaults to "no active tenant" (404).
|
||||
//
|
||||
// Owner and platform get the full org-scoped list and pick the first
|
||||
// tenant, matching the dashboard's "current instance" semantics.
|
||||
if (!teamId) {
|
||||
const tenants = await listTenants();
|
||||
const orgTenant = tenants.find(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
const allTenants = await listTenants();
|
||||
const visible = await listVisibleTenants(user, allTenants);
|
||||
const orgTenant = visible.find((t) => !!t.status?.litellmTeamId);
|
||||
|
||||
if (!orgTenant?.status?.litellmTeamId) {
|
||||
return NextResponse.json(
|
||||
|
||||
180
src/lib/db.ts
180
src/lib/db.ts
@@ -82,6 +82,39 @@ const MIGRATION_SQL = `
|
||||
content TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Slice 6: per-tenant user assignments
|
||||
-- ---------------------------------------------------------------------------
|
||||
--
|
||||
-- Each row grants ONE user visibility into ONE tenant within their own
|
||||
-- ZITADEL org. Used to narrow the customer 'user' role from "everything
|
||||
-- in the org" to "only the tenants I've been assigned to". Owners and
|
||||
-- platform users bypass this table entirely.
|
||||
--
|
||||
-- Composite PK is (tenant_name, zitadel_user_id) — a user is either
|
||||
-- assigned to a tenant or not, no degree.
|
||||
--
|
||||
-- The zitadel_org_id column is denormalised onto every row so cascade
|
||||
-- cleanups when a user leaves an org can be expressed as a single
|
||||
-- DELETE WHERE zitadel_org_id=$1 AND zitadel_user_id=$2 — without
|
||||
-- joining tenant_requests. The assigned_by column tracks which user
|
||||
-- (the owner usually) granted the assignment, for audit.
|
||||
--
|
||||
-- Cascade on tenant deletion is enforced in application code (the
|
||||
-- admin delete handler calls removeAllAssignmentsForTenant) rather
|
||||
-- than via FK — there's no FK target, since K8s CRs aren't a Postgres
|
||||
-- table.
|
||||
CREATE TABLE IF NOT EXISTS tenant_user_assignments (
|
||||
tenant_name TEXT NOT NULL,
|
||||
zitadel_org_id TEXT NOT NULL,
|
||||
zitadel_user_id TEXT NOT NULL,
|
||||
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
assigned_by TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_name, zitadel_user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tua_user ON tenant_user_assignments(zitadel_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tua_org ON tenant_user_assignments(zitadel_org_id);
|
||||
`;
|
||||
|
||||
let migrated = false;
|
||||
@@ -417,3 +450,150 @@ function mapRow(row: any): TenantRequest {
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slice 6: tenant ↔ user assignments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One assignment grants one user visibility into one tenant. Returned
|
||||
* shape is the camelCase mirror of the Postgres row.
|
||||
*/
|
||||
export interface TenantUserAssignment {
|
||||
tenantName: string;
|
||||
zitadelOrgId: string;
|
||||
zitadelUserId: string;
|
||||
assignedAt: string;
|
||||
assignedBy: string;
|
||||
}
|
||||
|
||||
function mapAssignmentRow(row: any): TenantUserAssignment {
|
||||
return {
|
||||
tenantName: row.tenant_name,
|
||||
zitadelOrgId: row.zitadel_org_id,
|
||||
zitadelUserId: row.zitadel_user_id,
|
||||
assignedAt: row.assigned_at?.toISOString?.() ?? row.assigned_at,
|
||||
assignedBy: row.assigned_by,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of tenant CR names assigned to the given user.
|
||||
*
|
||||
* Hot path on every read for `user`-role customers, so it's intentionally
|
||||
* a single indexed lookup. The returned array is small (a handful of
|
||||
* tenants per user); callers usually wrap it in a Set.
|
||||
*
|
||||
* Note: this does NOT cross-check the org id — assignments are per-user,
|
||||
* and a user's org context comes from their JWT. If a user's
|
||||
* authorization is revoked at the ZITADEL level, their JWT ceases to
|
||||
* carry the customer role and they can't reach the dashboard at all;
|
||||
* the orphan rows are cleaned up the next time their org membership
|
||||
* is re-evaluated (Slice 7's removeAllAssignmentsForUser).
|
||||
*/
|
||||
export async function listTenantAssignmentsForUser(
|
||||
userId: string
|
||||
): Promise<string[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query<{ tenant_name: string }>(
|
||||
"SELECT tenant_name FROM tenant_user_assignments WHERE zitadel_user_id = $1",
|
||||
[userId]
|
||||
);
|
||||
return result.rows.map((r) => r.tenant_name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all assignments for a single tenant. Used by the team UI
|
||||
* (Slice 7) to render "who has access to this instance". Includes
|
||||
* `assignedBy` and `assignedAt` for audit display.
|
||||
*/
|
||||
export async function listAssignmentsForTenant(
|
||||
tenantName: string
|
||||
): Promise<TenantUserAssignment[]> {
|
||||
await ensureSchema();
|
||||
const result = await getPool().query(
|
||||
"SELECT * FROM tenant_user_assignments WHERE tenant_name = $1 ORDER BY assigned_at DESC",
|
||||
[tenantName]
|
||||
);
|
||||
return result.rows.map(mapAssignmentRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a user access to a tenant. Idempotent — a duplicate INSERT
|
||||
* is silently ignored via ON CONFLICT, and the existing
|
||||
* `assigned_at`/`assigned_by` are preserved (we don't update them on
|
||||
* re-assign).
|
||||
*
|
||||
* Caller is responsible for verifying:
|
||||
* - The actor (`assignedBy`) holds owner/platform role in `orgId`.
|
||||
* - The target user (`userId`) is actually a member of the same
|
||||
* ZITADEL org. We don't validate this here — the team UI fetches
|
||||
* the org's user list from ZITADEL and selects from it.
|
||||
* - The tenant CR exists and is labelled with the same `orgId`.
|
||||
*/
|
||||
export async function addTenantAssignment(params: {
|
||||
tenantName: string;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
assignedBy: string;
|
||||
}): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
`INSERT INTO tenant_user_assignments
|
||||
(tenant_name, zitadel_org_id, zitadel_user_id, assigned_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (tenant_name, zitadel_user_id) DO NOTHING`,
|
||||
[params.tenantName, params.orgId, params.userId, params.assignedBy]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a user's access to a tenant. No-op if the row doesn't exist.
|
||||
*/
|
||||
export async function removeTenantAssignment(
|
||||
tenantName: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1 AND zitadel_user_id = $2",
|
||||
[tenantName, userId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade cleanup: drop ALL assignments for a tenant when the tenant
|
||||
* itself is deleted. Called from the admin delete handler.
|
||||
*
|
||||
* Without this, an orphan row would stick around forever — a future
|
||||
* tenant with the same name (won't happen given Slice 1's UUID-suffix
|
||||
* naming, but defense in depth) would inherit the old assignments.
|
||||
*/
|
||||
export async function removeAllAssignmentsForTenant(
|
||||
tenantName: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"DELETE FROM tenant_user_assignments WHERE tenant_name = $1",
|
||||
[tenantName]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade cleanup: drop ALL assignments for a user within a specific
|
||||
* org. Used by Slice 7's "remove member" flow when an owner kicks a
|
||||
* user out of the org. Scoped by `orgId` so a user with assignments in
|
||||
* org A doesn't lose them when removed from org B (multi-org users
|
||||
* exist when a person registers personally and is also invited to a
|
||||
* company).
|
||||
*/
|
||||
export async function removeAllAssignmentsForUser(
|
||||
orgId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await ensureSchema();
|
||||
await getPool().query(
|
||||
"DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2",
|
||||
[orgId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
127
src/lib/visibility.ts
Normal file
127
src/lib/visibility.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tenant visibility scoping for the customer-facing portal.
|
||||
*
|
||||
* Centralised here so every endpoint that lists or fetches tenants
|
||||
* agrees on the same rules. A bug in any one of those — say, a stale
|
||||
* inline filter that returned org-wide results to a `user`-role member
|
||||
* — would leak siblings' workspace files and channel-user lists.
|
||||
* One source of truth makes the audit easy.
|
||||
*
|
||||
* Visibility model
|
||||
* ----------------
|
||||
* platform_admin / platform_operator → all tenants in the cluster.
|
||||
* owner (customer) → all tenants in their own org.
|
||||
* user (customer, no owner role) → only tenants they've been
|
||||
* assigned to via the
|
||||
* tenant_user_assignments table.
|
||||
*
|
||||
* The narrowing for `user` is what turns the customer role into a
|
||||
* meaningful access boundary. Without it, every member of an org
|
||||
* would see every tenant — fine for a one-team SaaS, broken for a
|
||||
* company with separate Production / Staging / Sales instances where
|
||||
* the Sales team shouldn't see the Production workspace files.
|
||||
*
|
||||
* Owners do NOT get filtered against the assignment table even if
|
||||
* they happen to have rows in it. The owner role beats user-level
|
||||
* scoping — that's the point of being an owner.
|
||||
*/
|
||||
|
||||
import type { SessionUser, PiecedTenant } from "@/types";
|
||||
import { listTenantAssignmentsForUser } from "./db";
|
||||
|
||||
/** Internal classifier — "what's this caller's visibility scope?". */
|
||||
type Scope = "all" | "org" | "assigned";
|
||||
|
||||
function scopeFor(user: SessionUser): Scope {
|
||||
if (user.isPlatform) return "all";
|
||||
if (user.roles.includes("owner")) return "org";
|
||||
return "assigned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a list of tenants down to what `user` is allowed to see.
|
||||
*
|
||||
* Performs at most one DB query (only when scope is "assigned") and
|
||||
* runs the K8s-side filter in memory. The K8s list is already small
|
||||
* (≤100 tenants at pilot scale) so this is fine; if it grew we'd
|
||||
* push the filter down to the K8s label selector instead.
|
||||
*/
|
||||
export async function listVisibleTenants(
|
||||
user: SessionUser,
|
||||
all: PiecedTenant[]
|
||||
): Promise<PiecedTenant[]> {
|
||||
const scope = scopeFor(user);
|
||||
|
||||
if (scope === "all") return all;
|
||||
|
||||
const orgScoped = all.filter(
|
||||
(t) => t.metadata.labels?.["pieced.ch/zitadel-org-id"] === user.orgId
|
||||
);
|
||||
|
||||
if (scope === "org") return orgScoped;
|
||||
|
||||
// scope === "assigned" — narrow to the user's assignment list
|
||||
const assigned = await listTenantAssignmentsForUser(user.id);
|
||||
if (assigned.length === 0) return [];
|
||||
|
||||
const allowed = new Set(assigned);
|
||||
return orgScoped.filter((t) => allowed.has(t.metadata.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-tenant predicate. Returns true when `user` may see (and read
|
||||
* from) `tenant`. Mutating endpoints additionally need
|
||||
* `canMutate(user)` from `lib/session.ts` — visibility ≠ permission to
|
||||
* change.
|
||||
*
|
||||
* Returns false (rather than throwing) so handlers can map to the
|
||||
* status code that fits their semantics — usually 404 for read paths
|
||||
* (don't leak existence) and 403 for mutation paths (caller already
|
||||
* knew the tenant existed).
|
||||
*/
|
||||
export async function canUserSeeTenant(
|
||||
user: SessionUser,
|
||||
tenant: PiecedTenant
|
||||
): Promise<boolean> {
|
||||
const scope = scopeFor(user);
|
||||
|
||||
if (scope === "all") return true;
|
||||
|
||||
// org scope and assigned scope both require the tenant to belong
|
||||
// to the user's org — different orgs are never visible.
|
||||
if (tenant.metadata.labels?.["pieced.ch/zitadel-org-id"] !== user.orgId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (scope === "org") return true;
|
||||
|
||||
// scope === "assigned"
|
||||
const assigned = await listTenantAssignmentsForUser(user.id);
|
||||
return assigned.includes(tenant.metadata.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Should `user` see in-flight tenant requests on the dashboard?"
|
||||
*
|
||||
* Owners and platform users yes (they own the lifecycle); user-role
|
||||
* members no (they can't act on requests, and a request that isn't
|
||||
* yet a tenant has no assignment yet, so showing it would be a
|
||||
* permanent "pending" with no action they can take).
|
||||
*/
|
||||
export function canSeeInflightRequests(user: SessionUser): boolean {
|
||||
return scopeFor(user) !== "assigned";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience predicate used by client-side empty states. For
|
||||
* `user`-role members, the dashboard wants to distinguish between
|
||||
* "your org has no instances" (very rare; ask owner to set one up)
|
||||
* and "your org has instances but you're not assigned to any" (more
|
||||
* common; ask owner to grant access).
|
||||
*
|
||||
* Callers compute this off the difference between visible and
|
||||
* org-wide tenant lists; this helper just reifies the test.
|
||||
*/
|
||||
export function isUserScoped(user: SessionUser): boolean {
|
||||
return scopeFor(user) === "assigned";
|
||||
}
|
||||
@@ -104,7 +104,11 @@
|
||||
"inflightRequests": "Laufende Anfragen",
|
||||
"createInstance": "Neue Instanz erstellen",
|
||||
"createInstanceDescription": "Eine weitere KI-Assistent-Instanz für Ihre Organisation bereitstellen. Die Anfrage wird von einem Administrator geprüft, bevor die Instanz erstellt wird.",
|
||||
"noAccessNoInstances": "Ihre Organisation hat noch keine Instanzen. Bitte bitten Sie den Eigentümer der Organisation, eine einzurichten."
|
||||
"noAccessNoInstances": "Ihre Organisation hat noch keine Instanzen. Bitte bitten Sie den Eigentümer der Organisation, eine einzurichten.",
|
||||
"noAssignmentsTitle": "Keine Instanzen zugewiesen",
|
||||
"noAssignmentsDescription": "Ihre Organisation verfügt über Instanzen, aber Sie haben keinen Zugriff darauf erhalten. Bitten Sie den Eigentümer Ihrer Organisation, Sie einer Instanz zuzuweisen.",
|
||||
"noInstancesYetTitle": "Noch keine Instanzen",
|
||||
"noInstancesYetDescription": "Ihre Organisation verfügt noch über keine Instanzen. Bitten Sie den Eigentümer Ihrer Organisation, eine einzurichten."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -104,7 +104,11 @@
|
||||
"inflightRequests": "In-flight requests",
|
||||
"createInstance": "Create new instance",
|
||||
"createInstanceDescription": "Provision an additional AI assistant instance for your organization. The request will be reviewed by an administrator before the instance is created.",
|
||||
"noAccessNoInstances": "Your organization doesn't have any instances yet. Please ask the organization owner to set one up."
|
||||
"noAccessNoInstances": "Your organization doesn't have any instances yet. Please ask the organization owner to set one up.",
|
||||
"noAssignmentsTitle": "No instances assigned",
|
||||
"noAssignmentsDescription": "Your organization has instances, but you haven't been granted access to any of them. Please ask your organization owner to assign you to an instance.",
|
||||
"noInstancesYetTitle": "No instances yet",
|
||||
"noInstancesYetDescription": "Your organization doesn't have any instances yet. Please ask your organization owner to set one up."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -104,7 +104,11 @@
|
||||
"inflightRequests": "Demandes en cours",
|
||||
"createInstance": "Créer une nouvelle instance",
|
||||
"createInstanceDescription": "Provisionner une instance supplémentaire d'assistant IA pour votre organisation. La demande sera examinée par un administrateur avant la création de l'instance.",
|
||||
"noAccessNoInstances": "Votre organisation n'a pas encore d'instances. Demandez au propriétaire de l'organisation d'en configurer une."
|
||||
"noAccessNoInstances": "Votre organisation n'a pas encore d'instances. Demandez au propriétaire de l'organisation d'en configurer une.",
|
||||
"noAssignmentsTitle": "Aucune instance attribuée",
|
||||
"noAssignmentsDescription": "Votre organisation possède des instances, mais aucun accès ne vous a été accordé. Demandez au propriétaire de votre organisation de vous attribuer une instance.",
|
||||
"noInstancesYetTitle": "Pas encore d'instances",
|
||||
"noInstancesYetDescription": "Votre organisation ne possède pas encore d'instances. Demandez au propriétaire de votre organisation d'en configurer une."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agent",
|
||||
|
||||
@@ -104,7 +104,11 @@
|
||||
"inflightRequests": "Richieste in corso",
|
||||
"createInstance": "Crea nuova istanza",
|
||||
"createInstanceDescription": "Effettua il provisioning di un'ulteriore istanza dell'assistente IA per la tua organizzazione. La richiesta sarà esaminata da un amministratore prima della creazione dell'istanza.",
|
||||
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una."
|
||||
"noAccessNoInstances": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario dell'organizzazione di configurarne una.",
|
||||
"noAssignmentsTitle": "Nessuna istanza assegnata",
|
||||
"noAssignmentsDescription": "La tua organizzazione ha delle istanze, ma non ti è stato concesso l'accesso a nessuna di esse. Chiedi al proprietario della tua organizzazione di assegnarti a un'istanza.",
|
||||
"noInstancesYetTitle": "Nessuna istanza ancora",
|
||||
"noInstancesYetDescription": "La tua organizzazione non ha ancora istanze. Chiedi al proprietario della tua organizzazione di configurarne una."
|
||||
},
|
||||
"tenantDetail": {
|
||||
"agent": "Agente",
|
||||
|
||||
Reference in New Issue
Block a user