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",