fix(auth): resolve the tenant membership inside the tenant scope (coreci-chat#13)
ci / test-pglite (PGlite, default) (push) Failing after 1m50s
ci / test-postgres (Postgres 16, G-022 RLS) (push) Failing after 1m24s
ci / test-pglite (PGlite, default) (pull_request) Failing after 1m49s
ci / test-postgres (Postgres 16, G-022 RLS) (pull_request) Failing after 1m24s

This commit is contained in:
2026-10-27 06:58:13 +00:00
parent 9b4bbd01af
commit 68139ea307
+36 -27
View File
@@ -80,41 +80,51 @@ export async function provisionTenant(
createdUser = true;
}
// 3. Resolve-or-create the membership (tenant-scoped).
const membership = await resolveMembership(db, tenant.id, user.id);
if (!membership) {
const role: "admin" | "operator" | "viewer" = createdTenant ? "admin" : "viewer";
await withTenant(tenant.id, async (c: ScopedClient) => {
await c.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role)
VALUES ($1, $2, $3)`,
[tenant.id, user.id, role],
);
// Audit: provision event (only when we actually created something).
await appendAudit(c, {
tenantId: tenant.id,
eventType: "provision",
payload: {
workosOrgId,
workosUserId,
email,
role,
createdTenant,
createdUser,
},
userId: user.id,
});
// 3. Resolve-or-create the membership (tenant-scoped). The lookup runs
// INSIDE the tenant scope on purpose: under RLS a statement outside a
// scope sees zero rows, so resolving outside always missed an existing
// membership and re-inserted it (the unique constraint on
// (tenant_id, user_id) then rejected the second call). It also keeps the
// statement off the empty-scope crash path: casting an unset scope to
// uuid raised `invalid input syntax for type uuid: ""` under real
// Postgres (coreci-chat#13).
const role: "admin" | "operator" | "viewer" = createdTenant ? "admin" : "viewer";
const membership = await withTenant(tenant.id, async (c: ScopedClient) => {
const found = await resolveMembership(c, tenant.id, user.id);
if (found) return { role: found.role, created: false };
await c.query(
`INSERT INTO tenant_memberships (tenant_id, user_id, role)
VALUES ($1, $2, $3)`,
[tenant.id, user.id, role],
);
// Audit: provision event (only when we actually created something).
await appendAudit(c, {
tenantId: tenant.id,
eventType: "provision",
payload: {
workosOrgId,
workosUserId,
email,
role,
createdTenant,
createdUser,
},
userId: user.id,
});
return { role, created: true };
});
if (membership.created) {
return {
tenantId: tenant.id,
userId: user.id,
role,
role: membership.role,
createdTenant,
createdUser,
};
}
// Both exist + membership exists → idempotent. No audit (nothing changed).
// Both exist + membership exists -> idempotent. No audit (nothing changed).
return {
tenantId: tenant.id,
userId: user.id,
@@ -123,7 +133,6 @@ export async function provisionTenant(
createdUser: false,
};
}
async function resolveTenantByOrg(db: ProvisionDb, workosOrgId: string): Promise<TenantRow | null> {
const res = await db.query<TenantRow>(
"SELECT id, name FROM tenants WHERE workos_org_id = $1",