TenantAssignment and readside filtering
All checks were successful
Build and Push / build (push) Successful in 1m23s

This commit is contained in:
2026-04-26 22:58:30 +02:00
parent 7c4e20099d
commit 22fd5fb2cc
14 changed files with 598 additions and 54 deletions

View File

@@ -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
View 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";
}