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:
180
src/lib/db.ts
180
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<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]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user