TenantAssignment and readside filtering
All checks were successful
Build and Push / build (push) Successful in 1m23s
All checks were successful
Build and Push / build (push) Successful in 1m23s
This commit is contained in:
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";
|
||||
}
|
||||
Reference in New Issue
Block a user