diff --git a/scripts/verify-visibility.mjs b/scripts/verify-visibility.mjs
new file mode 100644
index 0000000..51b155b
--- /dev/null
+++ b/scripts/verify-visibility.mjs
@@ -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);
diff --git a/src/app/[locale]/dashboard/page.tsx b/src/app/[locale]/dashboard/page.tsx
index ec8cf8e..bbe866d 100644
--- a/src/app/[locale]/dashboard/page.tsx
+++ b/src/app/[locale]/dashboard/page.tsx
@@ -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 (
+
+
+
+ {t("title")}
+
+
+ {t("welcome", { name: user.name || user.email })}
+
+
+
+
+
+ {orgHasTenants
+ ? t("noAssignmentsTitle")
+ : t("noInstancesYetTitle")}
+
+
+ {orgHasTenants
+ ? t("noAssignmentsDescription")
+ : t("noInstancesYetDescription")}
+
+
+
+
+ );
+ }
+
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 (
diff --git a/src/app/[locale]/tenants/[name]/page.tsx b/src/app/[locale]/tenants/[name]/page.tsx
index 6898ef6..4c9f765 100644
--- a/src/app/[locale]/tenants/[name]/page.tsx
+++ b/src/app/[locale]/tenants/[name]/page.tsx
@@ -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();
}
diff --git a/src/app/api/admin/tenants/[name]/delete/route.ts b/src/app/api/admin/tenants/[name]/delete/route.ts
index 147a461..f5110ae 100644
--- a/src/app/api/admin/tenants/[name]/delete/route.ts
+++ b/src/app/api/admin/tenants/[name]/delete/route.ts
@@ -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({
diff --git a/src/app/api/onboarding/route.ts b/src/app/api/onboarding/route.ts
index bd9c60b..717b683 100644
--- a/src/app/api/onboarding/route.ts
+++ b/src/app/api/onboarding/route.ts
@@ -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),
});
}
diff --git a/src/app/api/tenants/[name]/route.ts b/src/app/api/tenants/[name]/route.ts
index f46e3e4..53ac925 100644
--- a/src/app/api/tenants/[name]/route.ts
+++ b/src/app/api/tenants/[name]/route.ts
@@ -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);
diff --git a/src/app/api/tenants/route.ts b/src/app/api/tenants/route.ts
index 4caa72e..dbcf36a 100644
--- a/src/app/api/tenants/route.ts
+++ b/src/app/api/tenants/route.ts
@@ -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);
}
diff --git a/src/app/api/usage/route.ts b/src/app/api/usage/route.ts
index 446e50d..40223a3 100644
--- a/src/app/api/usage/route.ts
+++ b/src/app/api/usage/route.ts
@@ -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(
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 7813bbf..9cd6187 100644
--- a/src/lib/db.ts
+++ b/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
{
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ await ensureSchema();
+ await getPool().query(
+ "DELETE FROM tenant_user_assignments WHERE zitadel_org_id = $1 AND zitadel_user_id = $2",
+ [orgId, userId]
+ );
+}
diff --git a/src/lib/visibility.ts b/src/lib/visibility.ts
new file mode 100644
index 0000000..7bc8f1e
--- /dev/null
+++ b/src/lib/visibility.ts
@@ -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 {
+ 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 {
+ 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";
+}
diff --git a/src/messages/de.json b/src/messages/de.json
index e30aae1..168f292 100644
--- a/src/messages/de.json
+++ b/src/messages/de.json
@@ -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",
diff --git a/src/messages/en.json b/src/messages/en.json
index f32f40d..2467d81 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -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",
diff --git a/src/messages/fr.json b/src/messages/fr.json
index 0cee44b..03b82f3 100644
--- a/src/messages/fr.json
+++ b/src/messages/fr.json
@@ -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",
diff --git a/src/messages/it.json b/src/messages/it.json
index e1a7eb3..213a98f 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -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",