Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 22 additions & 7 deletions API/src/services/team-hostname.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { PrismaClient } from '@prisma/client';
import { checkSlug, type SlugRejection } from '@unlikeotherai/slug';

import { getPrisma } from '../db/prisma.js';
import { getAdminPrisma } from '../db/prisma.js';
import { normalizeDomain } from '../utils/domain.js';

/**
Expand All @@ -18,6 +18,21 @@ import { normalizeDomain } from '../utils/domain.js';
* Every read here is scoped to the calling client domain. A product resolving a
* hostname may only ever see organisations on its own domain, which is the same
* boundary `@@unique([domain, slug])` draws.
*
* **These reads use the admin client, and the domain predicate is what confines
* them.** `organisations` and `teams` have FORCE ROW LEVEL SECURITY, and every
* other route reaches them through `request.withTenantTx(...)`, which sets the
* tenant context the policies read. A `/domain/*` route has no such context —
* it is authenticated by the domain hash, with no user and no active
* organisation — so the tenant-scoped client matches nothing at all here. It
* does not error: it silently returns no rows, which made every resolve answer
* 404 and every availability check answer "available", including for slugs that
* were plainly taken.
*
* So the confinement is explicit instead: every query below filters on the
* caller's own `domain`, and for teams on their organisation's. Anything added
* to this file must keep that predicate — it is now the only thing standing
* between a product and another product's tenants.
*/

type HostnamePrisma = {
Expand Down Expand Up @@ -46,7 +61,7 @@ export async function resolveTeamHostname(
const teamSlug = params.teamSlug.trim().toLowerCase();
if (!orgSlug || !teamSlug) return null;

const prisma = deps.prisma ?? (getPrisma() as unknown as HostnamePrisma);
const prisma = deps.prisma ?? (getAdminPrisma() as unknown as HostnamePrisma);

const org = await prisma.organisation.findFirst({
where: { domain, slug: orgSlug },
Expand Down Expand Up @@ -100,7 +115,7 @@ export async function resolveOrgHostname(
const orgSlug = params.orgSlug.trim().toLowerCase();
if (!orgSlug) return null;

const prisma = deps.prisma ?? (getPrisma() as unknown as HostnamePrisma);
const prisma = deps.prisma ?? (getAdminPrisma() as unknown as HostnamePrisma);
const org = await prisma.organisation.findFirst({
where: { domain, slug: orgSlug },
select: { id: true, name: true, slug: true, iconUrl: true },
Expand Down Expand Up @@ -128,7 +143,7 @@ export async function resolveOrgById(
params: { domain: string; orgId: string },
deps: HostnameDeps = {},
): Promise<ResolvedOrgHostname | null> {
const prisma = deps.prisma ?? (getPrisma() as unknown as HostnamePrisma);
const prisma = deps.prisma ?? (getAdminPrisma() as unknown as HostnamePrisma);
const org = await prisma.organisation.findFirst({
// Scoped to the calling client domain like every other /domain/* read: a
// product may only ever ask about organisations on its own domain, even
Expand Down Expand Up @@ -167,7 +182,7 @@ export async function resolveTeamById(
params: { domain: string; teamId: string },
deps: HostnameDeps = {},
): Promise<ResolvedTeamById | null> {
const prisma = deps.prisma ?? (getPrisma() as unknown as HostnamePrisma);
const prisma = deps.prisma ?? (getAdminPrisma() as unknown as HostnamePrisma);
const team = await prisma.team.findFirst({
where: {
id: params.teamId.trim(),
Expand Down Expand Up @@ -212,7 +227,7 @@ export async function checkOrgSlugAvailability(
const result = checkSlug(params.slug, { reserved: params.reservedLabels });
if (!result.ok) return { available: false, reason: result.reason };

const prisma = deps.prisma ?? (getPrisma() as unknown as HostnamePrisma);
const prisma = deps.prisma ?? (getAdminPrisma() as unknown as HostnamePrisma);
const existing = await prisma.organisation.findFirst({
where: { domain: normalizeDomain(params.domain), slug: result.slug },
select: { id: true },
Expand All @@ -236,7 +251,7 @@ export async function checkTeamSlugAvailability(
const result = checkSlug(params.slug, { reserved: params.reservedLabels });
if (!result.ok) return { available: false, reason: result.reason };

const prisma = deps.prisma ?? (getPrisma() as unknown as HostnamePrisma);
const prisma = deps.prisma ?? (getAdminPrisma() as unknown as HostnamePrisma);
const existing = await prisma.team.findFirst({
where: { orgId: params.orgId, slug: result.slug },
select: { id: true },
Expand Down
138 changes: 138 additions & 0 deletions API/tests/integration/team-hostname-rls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';

import {
checkOrgSlugAvailability,
resolveOrgById,
resolveOrgHostname,
resolveTeamById,
resolveTeamHostname,
} from '../../src/services/team-hostname.service.js';
import { PrismaClient } from '@prisma/client';

import { createRlsTestDb } from '../helpers/test-db.js';

const hasDatabase = Boolean(process.env.DATABASE_URL);
const DOMAIN = 'client.example.com';

/**
* These call the hostname service WITHOUT injecting a prisma client, which is
* the whole point.
*
* The unit tests all pass `deps.prisma`, so they exercised the query shapes and
* never the client that runs them — and the service was reaching for the
* tenant-scoped client on routes that have no tenant context. `organisations`
* and `teams` have FORCE ROW LEVEL SECURITY, so every read matched zero rows.
* It did not throw: resolution answered "no such tenant" and availability
* answered "available" for slugs that were plainly taken, in production, for as
* long as the feature existed.
*
* A mocked client cannot see that. Only a real one can.
*/
describe.skipIf(!hasDatabase)('hostname reads run against real row-level security', () => {
let handle: Awaited<ReturnType<typeof createRlsTestDb>>;
let appPrisma: PrismaClient;
let adminPrisma: PrismaClient;
const originalDatabaseUrl = process.env.DATABASE_URL;

beforeAll(async () => {
handle = await createRlsTestDb();
if (!handle) throw new Error('DATABASE_URL is required for DB-backed tests');
// `uoa_app` is the non-BYPASSRLS role the API runs as; `uoa_admin` is the
// BYPASSRLS role `DATABASE_ADMIN_URL` names in production, which is what a
// domain-hash route must use. Seeding stays on handle.prisma (superuser).
appPrisma = new PrismaClient({ datasources: { db: { url: handle.appDatabaseUrl } } });
adminPrisma = new PrismaClient({ datasources: { db: { url: handle.adminDatabaseUrl } } });
});

afterAll(async () => {
process.env.DATABASE_URL = originalDatabaseUrl;
await appPrisma?.$disconnect();
await adminPrisma?.$disconnect();
if (handle) await handle.cleanup();
});

beforeEach(async () => {
if (!handle) return;
await handle.prisma.team.deleteMany();
await handle.prisma.organisation.deleteMany();
await handle.prisma.user.deleteMany();
});

const seed = async () => {
const owner = await handle.prisma.user.create({
data: { email: 'owner@example.com', userKey: 'owner@example.com' },
select: { id: true },
});
const org = await handle.prisma.organisation.create({
data: { domain: DOMAIN, name: 'Acme', slug: 'acme', ownerId: owner.id },
select: { id: true },
});
const team = await handle.prisma.team.create({
data: { orgId: org.id, name: 'Design', slug: 'design' },
select: { id: true },
});
return { orgId: org.id, teamId: team.id };
};

it('finds an organisation by its slug', async () => {
const { orgId } = await seed();
const resolved = await resolveOrgHostname({ domain: DOMAIN, orgSlug: 'acme' }, { prisma: adminPrisma });
expect(resolved).toMatchObject({ orgId, orgSlug: 'acme', orgName: 'Acme' });
});

it('finds a team beneath its organisation', async () => {
const { orgId, teamId } = await seed();
const resolved = await resolveTeamHostname(
{ domain: DOMAIN, orgSlug: 'acme', teamSlug: 'design' },
{ prisma: adminPrisma },
);
expect(resolved).toMatchObject({ orgId, teamId, teamSlug: 'design' });
});

it('reports a taken slug as taken, not as available', async () => {
await seed();
// The exact failure this file exists for: with no rows visible, this
// answered `available: true` and would have let a second tenant be told a
// taken address was free.
expect(await checkOrgSlugAvailability({ domain: DOMAIN, slug: 'acme' }, { prisma: adminPrisma })).toEqual({
available: false,
reason: 'taken',
});
});

it('resolves an address back from an id', async () => {
const { orgId, teamId } = await seed();
expect(await resolveOrgById({ domain: DOMAIN, orgId }, { prisma: adminPrisma })).toMatchObject({ orgSlug: 'acme' });
expect(await resolveTeamById({ domain: DOMAIN, teamId }, { prisma: adminPrisma })).toMatchObject({
teamSlug: 'design',
orgSlug: 'acme',
});
});

it('still refuses another client domain, which is now the only boundary', async () => {
const { orgId, teamId } = await seed();
// The tenant-scoped client is gone, so the domain predicate is what keeps
// one product out of another's tenants. Prove it holds.
expect(await resolveOrgHostname({ domain: 'other.example.com', orgSlug: 'acme' }, { prisma: adminPrisma })).toBeNull();
expect(await resolveOrgById({ domain: 'other.example.com', orgId }, { prisma: adminPrisma })).toBeNull();
expect(await resolveTeamById({ domain: 'other.example.com', teamId }, { prisma: adminPrisma })).toBeNull();
expect(
await checkOrgSlugAvailability({ domain: 'other.example.com', slug: 'acme' }, { prisma: adminPrisma }),
).toEqual({ available: true, slug: 'acme' });
});

it('the runtime RLS role sees nothing here — which is the bug that shipped', async () => {
await seed();
// `uoa_app` cannot satisfy any branch of organisations_select on a
// domain-hash route: there is no app.org_id, no app.user_id, and the
// domain branch additionally requires an org_members row for that user.
// It does not error, it returns nothing — so resolution answered 404 and
// availability answered "available" for a slug that was taken.
expect(
await resolveOrgHostname({ domain: DOMAIN, orgSlug: 'acme' }, { prisma: appPrisma }),
).toBeNull();
expect(
await checkOrgSlugAvailability({ domain: DOMAIN, slug: 'acme' }, { prisma: appPrisma }),
).toEqual({ available: true, slug: 'acme' });
});
});
Loading