/** * 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"; }