diff --git a/.server-changes/sentry-tenant-attribution.md b/.server-changes/sentry-tenant-attribution.md new file mode 100644 index 00000000000..2aaf43483ab --- /dev/null +++ b/.server-changes/sentry-tenant-attribution.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Stamp Sentry events with the signed-in user so "Users Impacted" counts individual humans, and enrich events with org / project / environment tags when that context is available (dashboard URLs, authenticated API requests). diff --git a/apps/webapp/app/entry.server.tsx b/apps/webapp/app/entry.server.tsx index 11c3274e865..a2d4a6a64c6 100644 --- a/apps/webapp/app/entry.server.tsx +++ b/apps/webapp/app/entry.server.tsx @@ -1,6 +1,8 @@ import { createReadableStreamFromReadable, type EntryContext } from "@remix-run/node"; // or cloudflare/deno import { RemixServer } from "@remix-run/react"; +import * as Sentry from "@sentry/remix"; import { wrapHandleErrorWithSentry } from "@sentry/remix"; +import { addTenantContextToEvent } from "~/utils/sentryTenantContext.server"; import { parseAcceptLanguage } from "intl-parse-accept-language"; import isbot from "isbot"; import { renderToPipeableStream } from "react-dom/server"; @@ -289,9 +291,24 @@ process.on("uncaughtException", (error, origin) => { singleton("RunEngineEventBusHandlers", registerRunEngineEventBusHandlers); singleton("SetupBatchQueueCallbacks", setupBatchQueueCallbacks); +// Wrapped in singleton() so Remix's dev-mode CJS reloads don't append +// duplicate copies of the processor — Sentry's processor list lives in +// node_modules and persists across module reloads. Idempotent at runtime +// (the processor is a pure read+stamp), but the pattern matches the rest +// of this file. +singleton("SentryTenantContextProcessor", () => { + if (env.SENTRY_DSN) { + Sentry.addEventProcessor(addTenantContextToEvent); + } + // Return a truthy value — `singleton()` uses `??=` so a `void` + // callback would re-execute (and re-register) on every dev reload. + return true; +}); + export { apiRateLimiter } from "./services/apiRateLimit.server"; export { engineRateLimiter } from "./services/engineRateLimit.server"; export { runWithHttpContext } from "./services/httpAsyncStorage.server"; +export { tenantContextMiddleware } from "./services/tenantContextResolver.server"; export { socketIo } from "./v3/handleSocketIo.server"; export { wss } from "./v3/handleWebsockets.server"; diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 6fb6c4ac283..53b813d15a5 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -134,6 +134,7 @@ const EnvironmentSchema = z ELECTRIC_ORIGIN_SHARDS: z.string().optional(), APP_ENV: z.string().default(process.env.NODE_ENV), SERVICE_NAME: z.string().default("trigger.dev webapp"), + SENTRY_DSN: z.string().optional(), POSTHOG_PROJECT_KEY: z.string().default("phc_LFH7kJiGhdIlnO22hTAKgHpaKhpM8gkzWAFvHmf5vfS"), TRIGGER_TELEMETRY_DISABLED: z.string().optional(), AUTH_GITHUB_CLIENT_ID: z.string().optional(), diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx index df9ee24be90..98b8c442218 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam/route.tsx @@ -5,6 +5,7 @@ import { redirectWithErrorMessage } from "~/models/message.server"; import { updateCurrentProjectEnvironmentId } from "~/services/dashboardPreferences.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; +import { tenantContext } from "~/services/tenantContext.server"; import { EnvironmentParamSchema, v3ProjectPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { @@ -26,6 +27,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, select: { id: true, + externalRef: true, + organization: { select: { id: true } }, environments: { select: { id: true, @@ -52,6 +55,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } let environmentId: string | undefined = undefined; + let environmentType: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION" | undefined; if (environments.length > 1) { const bestEnvironment = environments.find((env) => env.orgMember?.userId === user.id); @@ -63,10 +67,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { } environmentId = bestEnvironment.id; + environmentType = bestEnvironment.type; } else { environmentId = environments[0].id; + environmentType = environments[0].type; } + // userId is enriched higher up in `_app/route.tsx`; only stamp tenant fields here. + tenantContext.enrich({ + orgId: project.organization.id, + projectId: project.id, + projectRef: project.externalRef, + envId: environmentId, + envType: environmentType, + }); + await updateCurrentProjectEnvironmentId({ user: user, projectId: project.id, environmentId }); return project; diff --git a/apps/webapp/app/routes/_app/route.tsx b/apps/webapp/app/routes/_app/route.tsx index 23e1b3834c6..db12ae90b60 100644 --- a/apps/webapp/app/routes/_app/route.tsx +++ b/apps/webapp/app/routes/_app/route.tsx @@ -5,10 +5,12 @@ import { RouteErrorDisplay } from "~/components/ErrorDisplay"; import { AppContainer, MainCenteredContainer } from "~/components/layout/AppLayout"; import { clearRedirectTo, commitSession } from "~/services/redirectTo.server"; import { requireUser } from "~/services/session.server"; +import { tenantContext } from "~/services/tenantContext.server"; import { confirmBasicDetailsPath } from "~/utils/pathBuilder"; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await requireUser(request); + tenantContext.enrich({ userId: user.id }); //you have to confirm basic details before you can do anything if (!user.confirmedBasicDetails) { diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 3bf3564431a..a66de63e113 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -19,6 +19,10 @@ import { API_VERSIONS, getApiVersion } from "~/api/versions"; import { WORKER_HEADERS } from "@trigger.dev/core/v3/runEngineWorker"; import { ServiceValidationError } from "~/v3/services/common.server"; import { EngineServiceValidationError } from "@internal/run-engine"; +import { + tenantContext, + tenantContextFromAuthEnvironment, +} from "~/services/tenantContext.server"; // Client aborts and service-level validation errors aren't bugs — they're // expected at API boundaries. Log them at `warn` so they stay in stdout @@ -357,15 +361,19 @@ export function createLoaderApiRoute< const apiVersion = getApiVersion(request); - const result = await handler({ - params: parsedParams, - searchParams: parsedSearchParams, - headers: parsedHeaders, - authentication: authenticationResult, - request, - resource, - apiVersion, - }); + const result = await tenantContext.run( + tenantContextFromAuthEnvironment(authenticationResult.environment), + () => + handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + authentication: authenticationResult, + request, + resource, + apiVersion, + }) + ); return await wrapResponse(request, result, corsStrategy !== "none"); } catch (error) { try { @@ -586,6 +594,11 @@ export function createLoaderPATApiRoute< } } + // PAT auth carries `userId` but no environment — enrich the scope + // the Express middleware established with the authenticated user so + // Sentry events from this handler get user-level attribution. + tenantContext.enrich({ userId: authenticationResult.userId }); + const result = await handler({ params: parsedParams, searchParams: parsedSearchParams, @@ -903,15 +916,19 @@ export function createActionApiRoute< ); } - const result = await handler({ - params: parsedParams, - searchParams: parsedSearchParams, - headers: parsedHeaders, - body: parsedBody, - authentication: authenticationResult, - request, - resource, - }); + const result = await tenantContext.run( + tenantContextFromAuthEnvironment(authenticationResult.environment), + () => + handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + body: parsedBody, + authentication: authenticationResult, + request, + resource, + }) + ); return await wrapResponse(request, result, corsStrategy !== "none"); } catch (error) { try { @@ -1156,14 +1173,18 @@ export function createMultiMethodApiRoute< } // Dispatch to method handler - const result = await methodConfig.handler({ - params: parsedParams, - searchParams: parsedSearchParams, - headers: parsedHeaders, - body: parsedBody, - authentication: authenticationResult, - request, - }); + const result = await tenantContext.run( + tenantContextFromAuthEnvironment(authenticationResult.environment), + () => + methodConfig.handler({ + params: parsedParams, + searchParams: parsedSearchParams, + headers: parsedHeaders, + body: parsedBody, + authentication: authenticationResult, + request, + }) + ); return await wrapResponse(request, result, corsStrategy !== "none"); } catch (error) { try { diff --git a/apps/webapp/app/services/tenantContext.server.ts b/apps/webapp/app/services/tenantContext.server.ts new file mode 100644 index 00000000000..0cadaa9f4c2 --- /dev/null +++ b/apps/webapp/app/services/tenantContext.server.ts @@ -0,0 +1,50 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { AuthenticatedEnvironment } from "./apiAuth.server"; + +// All fields are optional. The middleware establishes an empty scope per +// request; entry points fill what they know: +// - URL-matching paths get the slug trio from the Express middleware (zero IO). +// - The `_app` layout adds `userId` for any authenticated request. +// - The env layout adds tenant IDs / env type after its own existing DB query. +// - API routes get the full set up-front from `authenticationResult.environment`. +export type TenantContext = { + userId?: string; + orgSlug?: string; + projectSlug?: string; + envSlug?: string; + orgId?: string; + projectId?: string; + projectRef?: string; + envId?: string; + envType?: "DEVELOPMENT" | "PREVIEW" | "STAGING" | "PRODUCTION"; + impersonating?: boolean; +}; + +const storage = new AsyncLocalStorage(); + +export const tenantContext = { + run(ctx: TenantContext, fn: () => T): T { + return storage.run(ctx, fn); + }, + get(): TenantContext | undefined { + return storage.getStore(); + }, + enrich(patch: Partial): void { + const current = storage.getStore(); + if (current) Object.assign(current, patch); + }, +}; + +export function tenantContextFromAuthEnvironment(env: AuthenticatedEnvironment): TenantContext { + return { + userId: env.orgMember?.userId, + orgSlug: env.organization.slug, + projectSlug: env.project.slug, + envSlug: env.slug, + orgId: env.organization.id, + projectId: env.project.id, + projectRef: env.project.externalRef, + envId: env.id, + envType: env.type, + }; +} diff --git a/apps/webapp/app/services/tenantContextResolver.server.ts b/apps/webapp/app/services/tenantContextResolver.server.ts new file mode 100644 index 00000000000..764670df95f --- /dev/null +++ b/apps/webapp/app/services/tenantContextResolver.server.ts @@ -0,0 +1,42 @@ +import type { NextFunction, Request, Response } from "express"; +import { tenantContext, type TenantContext } from "./tenantContext.server"; + +const URL_PATTERN = /^\/orgs\/([^/]+)(?:\/projects\/([^/]+)(?:\/env\/([^/]+))?)?/; + +export type ParsedTenantPath = { + orgSlug: string; + projectSlug?: string; + envSlug?: string; +}; + +// Pulls whatever tenant slugs are present in the URL. `/orgs/:o` returns the +// org alone; `/orgs/:o/projects/:p` adds the project; `/orgs/:o/projects/:p/env/:e` +// returns all three. Non-tenant paths (`/`, `/login`, `/admin/*`) return undefined. +export function parseTenantPath(pathname: string): ParsedTenantPath | undefined { + const match = pathname.match(URL_PATTERN); + if (!match) return undefined; + const [, orgSlug, projectSlug, envSlug] = match; + if (!orgSlug) return undefined; + return { + orgSlug, + ...(projectSlug ? { projectSlug } : {}), + ...(envSlug ? { envSlug } : {}), + }; +} + +export function resolveTenantContextFromPath(pathname: string): TenantContext { + return parseTenantPath(pathname) ?? {}; +} + +export type PathResolver = (pathname: string) => TenantContext; + +export function createTenantContextMiddleware(resolver: PathResolver) { + // Always establish an ALS scope, even when the path carries no tenant + // slugs. Authenticated loaders (e.g. the `_app` layout) then enrich the + // same scope with `userId`, so non-tenant pages still get user attribution. + return function tenantContextMiddleware(req: Request, res: Response, next: NextFunction) { + tenantContext.run(resolver(req.path), () => next()); + }; +} + +export const tenantContextMiddleware = createTenantContextMiddleware(resolveTenantContextFromPath); diff --git a/apps/webapp/app/utils/sentryTenantContext.server.ts b/apps/webapp/app/utils/sentryTenantContext.server.ts new file mode 100644 index 00000000000..303c3b32739 --- /dev/null +++ b/apps/webapp/app/utils/sentryTenantContext.server.ts @@ -0,0 +1,26 @@ +import type { Event, EventHint } from "@sentry/remix"; +import { tenantContext } from "../services/tenantContext.server"; + +export function addTenantContextToEvent(event: Event, _hint: EventHint): Event { + const ctx = tenantContext.get(); + if (!ctx) return event; + return { + ...event, + // Only stamp user.id when we have a real user — keeps "Users Impacted" + // counting distinct humans rather than mixing in tenants. Events without + // a known user (e.g. unauthenticated paths) skip user attribution. + ...(ctx.userId ? { user: { ...event.user, id: ctx.userId } } : {}), + tags: { + ...event.tags, + ...(ctx.orgSlug ? { org_slug: ctx.orgSlug } : {}), + ...(ctx.projectSlug ? { project_slug: ctx.projectSlug } : {}), + ...(ctx.envSlug ? { env_slug: ctx.envSlug } : {}), + ...(ctx.orgId ? { org_id: ctx.orgId } : {}), + ...(ctx.projectId ? { project_id: ctx.projectId } : {}), + ...(ctx.projectRef ? { project_ref: ctx.projectRef } : {}), + ...(ctx.envId ? { environment_id: ctx.envId } : {}), + ...(ctx.envType ? { env_type: ctx.envType } : {}), + ...(ctx.impersonating ? { impersonating: "true" } : {}), + }, + }; +} diff --git a/apps/webapp/server.ts b/apps/webapp/server.ts index e266c6985c8..9c83f243213 100644 --- a/apps/webapp/server.ts +++ b/apps/webapp/server.ts @@ -122,6 +122,8 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter; const engineRateLimiter: RateLimitMiddleware = build.entry.module.engineRateLimiter; const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext; + const tenantContextMiddleware: import("express").RequestHandler = + build.entry.module.tenantContextMiddleware; app.use((req, res, next) => { // helpful headers: @@ -171,6 +173,8 @@ if (ENABLE_CLUSTER && cluster.isPrimary) { app.use(apiRateLimiter); app.use(engineRateLimiter); + app.use(tenantContextMiddleware); + app.all( "*", // @ts-ignore diff --git a/apps/webapp/test/sentryTenantContext.test.ts b/apps/webapp/test/sentryTenantContext.test.ts new file mode 100644 index 00000000000..3cba145be7e --- /dev/null +++ b/apps/webapp/test/sentryTenantContext.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from "vitest"; +import type { Event } from "@sentry/remix"; +import { tenantContext } from "../app/services/tenantContext.server"; +import { addTenantContextToEvent } from "../app/utils/sentryTenantContext.server"; + +const slugOnly = { + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", +}; + +const enrichedWithUser = { + ...slugOnly, + userId: "usr_42", + orgId: "org_1", + projectId: "proj_1", + projectRef: "proj_abc", + envId: "env_1", + envType: "PRODUCTION" as const, +}; + +describe("addTenantContextToEvent", () => { + it("returns the event unchanged when no ALS context", () => { + const event: Event = { message: "hi", tags: { existing: "1" } }; + const out = addTenantContextToEvent(event, {}); + expect(out).toEqual(event); + }); + + it("stamps only userId when the scope holds just a user (non-tenant page)", () => { + tenantContext.run({ userId: "usr_42" }, () => { + const event: Event = { message: "boom", tags: { existing: "1" } }; + const out = addTenantContextToEvent(event, {}); + expect(out.user).toEqual({ id: "usr_42" }); + expect(out.tags).toEqual({ existing: "1" }); + }); + }); + + it("stamps slug tags and no user.id when only slugs are set", () => { + tenantContext.run(slugOnly, () => { + const event: Event = { message: "boom", tags: { existing: "1" } }; + const out = addTenantContextToEvent(event, {}); + expect(out.user).toBeUndefined(); + expect(out.tags).toMatchObject({ + existing: "1", + org_slug: "acme", + project_slug: "web", + env_slug: "prod", + }); + expect(out.tags?.org_id).toBeUndefined(); + expect(out.tags?.env_type).toBeUndefined(); + }); + }); + + it("stamps user.id + full tag set when fully enriched", () => { + tenantContext.run(enrichedWithUser, () => { + const out = addTenantContextToEvent({}, {}); + expect(out.user).toEqual({ id: "usr_42" }); + expect(out.tags).toMatchObject({ + org_slug: "acme", + project_slug: "web", + env_slug: "prod", + org_id: "org_1", + project_id: "proj_1", + project_ref: "proj_abc", + environment_id: "env_1", + env_type: "PRODUCTION", + }); + }); + }); + + it("emits no slug/ID tags when scope is empty", () => { + tenantContext.run({}, () => { + const out = addTenantContextToEvent({ tags: { existing: "1" } }, {}); + expect(out.tags).toEqual({ existing: "1" }); + expect(out.user).toBeUndefined(); + }); + }); + + it("adds impersonating tag when flag set", () => { + tenantContext.run({ ...slugOnly, impersonating: true }, () => { + const out = addTenantContextToEvent({}, {}); + expect(out.tags?.impersonating).toBe("true"); + }); + }); + + it("preserves prior event.user fields it does not own", () => { + tenantContext.run(enrichedWithUser, () => { + const event: Event = { user: { ip_address: "1.2.3.4" } }; + const out = addTenantContextToEvent(event, {}); + expect(out.user).toEqual({ ip_address: "1.2.3.4", id: "usr_42" }); + }); + }); +}); diff --git a/apps/webapp/test/tenantContext.test.ts b/apps/webapp/test/tenantContext.test.ts new file mode 100644 index 00000000000..c90c139eacc --- /dev/null +++ b/apps/webapp/test/tenantContext.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "vitest"; +import { tenantContext, type TenantContext } from "../app/services/tenantContext.server"; + +const sample: TenantContext = { + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", +}; + +describe("tenantContext", () => { + it("returns undefined outside run()", () => { + expect(tenantContext.get()).toBeUndefined(); + }); + + it("returns the active context inside run()", () => { + tenantContext.run(sample, () => { + expect(tenantContext.get()).toEqual(sample); + }); + }); + + it("isolates concurrent async trees", async () => { + const a: TenantContext = { ...sample, orgSlug: "a" }; + const b: TenantContext = { ...sample, orgSlug: "b" }; + + const [got1, got2] = await Promise.all([ + tenantContext.run(a, async () => { + await new Promise((r) => setTimeout(r, 10)); + return tenantContext.get()?.orgSlug; + }), + tenantContext.run(b, async () => { + await new Promise((r) => setTimeout(r, 5)); + return tenantContext.get()?.orgSlug; + }), + ]); + expect(got1).toBe("a"); + expect(got2).toBe("b"); + }); + + it("supports nested run() overriding", () => { + const inner: TenantContext = { ...sample, orgSlug: "inner" }; + tenantContext.run(sample, () => { + tenantContext.run(inner, () => { + expect(tenantContext.get()?.orgSlug).toBe("inner"); + }); + expect(tenantContext.get()?.orgSlug).toBe("acme"); + }); + }); + + it("enrich() patches the active context in-place", () => { + tenantContext.run({ ...sample }, () => { + tenantContext.enrich({ + userId: "usr_1", + orgId: "org_1", + projectId: "proj_1", + envType: "PRODUCTION", + }); + expect(tenantContext.get()).toMatchObject({ + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", + userId: "usr_1", + orgId: "org_1", + projectId: "proj_1", + envType: "PRODUCTION", + }); + }); + }); + + it("enrich() outside run() is a no-op", () => { + expect(() => tenantContext.enrich({ orgId: "x" })).not.toThrow(); + expect(tenantContext.get()).toBeUndefined(); + }); + + it("supports starting from an empty scope and enriching userId only (non-tenant page)", () => { + tenantContext.run({}, () => { + tenantContext.enrich({ userId: "usr_1" }); + expect(tenantContext.get()).toEqual({ userId: "usr_1" }); + }); + }); + + it("enrich() patches do not bleed across concurrent run() scopes", async () => { + const a: TenantContext = { ...sample, orgSlug: "a" }; + const b: TenantContext = { ...sample, orgSlug: "b" }; + const [got1, got2] = await Promise.all([ + tenantContext.run(a, async () => { + await new Promise((r) => setTimeout(r, 5)); + tenantContext.enrich({ orgId: "org_a" }); + await new Promise((r) => setTimeout(r, 5)); + return tenantContext.get(); + }), + tenantContext.run(b, async () => { + await new Promise((r) => setTimeout(r, 10)); + tenantContext.enrich({ orgId: "org_b" }); + return tenantContext.get(); + }), + ]); + expect(got1?.orgId).toBe("org_a"); + expect(got2?.orgId).toBe("org_b"); + }); +}); diff --git a/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts b/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts new file mode 100644 index 00000000000..163338c0728 --- /dev/null +++ b/apps/webapp/test/tenantContextFromAuthEnvironment.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; +import { tenantContextFromAuthEnvironment } from "../app/services/tenantContext.server"; +import type { AuthenticatedEnvironment } from "../app/services/apiAuth.server"; + +const baseEnv = { + id: "env_1", + slug: "prod", + type: "PRODUCTION" as const, + organization: { id: "org_1", slug: "acme" }, + project: { id: "proj_1", slug: "web", externalRef: "proj_abc" }, +}; + +const envWithOrgMember = { + ...baseEnv, + orgMember: { userId: "usr_42" }, +} as unknown as AuthenticatedEnvironment; + +const envWithoutOrgMember = { + ...baseEnv, + orgMember: null, +} as unknown as AuthenticatedEnvironment; + +describe("tenantContextFromAuthEnvironment", () => { + it("returns the full tenant context (slugs + IDs + env type + userId) when orgMember is present", () => { + expect(tenantContextFromAuthEnvironment(envWithOrgMember)).toEqual({ + userId: "usr_42", + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", + orgId: "org_1", + projectId: "proj_1", + projectRef: "proj_abc", + envId: "env_1", + envType: "PRODUCTION", + }); + }); + + it("omits userId when there is no orgMember on the environment", () => { + const ctx = tenantContextFromAuthEnvironment(envWithoutOrgMember); + expect(ctx.userId).toBeUndefined(); + expect(ctx.orgSlug).toBe("acme"); + expect(ctx.envSlug).toBe("prod"); + }); + + it("does not propagate impersonating (auth environments are real, not impersonated)", () => { + expect(tenantContextFromAuthEnvironment(envWithOrgMember).impersonating).toBeUndefined(); + }); +}); diff --git a/apps/webapp/test/tenantContextResolver.test.ts b/apps/webapp/test/tenantContextResolver.test.ts new file mode 100644 index 00000000000..e531a11bad8 --- /dev/null +++ b/apps/webapp/test/tenantContextResolver.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi } from "vitest"; +import { + createTenantContextMiddleware, + parseTenantPath, + resolveTenantContextFromPath, + type PathResolver, +} from "../app/services/tenantContextResolver.server"; +import { tenantContext, type TenantContext } from "../app/services/tenantContext.server"; + +const sampleCtx: TenantContext = { + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", +}; + +describe("parseTenantPath", () => { + it("parses a full env path", () => { + expect(parseTenantPath("/orgs/acme/projects/web/env/prod")).toEqual({ + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", + }); + }); + + it("parses a path with extra segments after env", () => { + expect(parseTenantPath("/orgs/acme/projects/web/env/prod/runs/run_1")).toEqual({ + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", + }); + }); + + it("returns undefined for non-orgs paths", () => { + expect(parseTenantPath("/healthcheck")).toBeUndefined(); + expect(parseTenantPath("/")).toBeUndefined(); + expect(parseTenantPath("/api/v1/tasks")).toBeUndefined(); + }); + + it("returns org-only when path has just the org slug", () => { + expect(parseTenantPath("/orgs/acme")).toEqual({ orgSlug: "acme" }); + expect(parseTenantPath("/orgs/acme/")).toEqual({ orgSlug: "acme" }); + expect(parseTenantPath("/orgs/acme/settings")).toEqual({ orgSlug: "acme" }); + }); + + it("returns org + project when env is missing", () => { + expect(parseTenantPath("/orgs/acme/projects/web")).toEqual({ + orgSlug: "acme", + projectSlug: "web", + }); + expect(parseTenantPath("/orgs/acme/projects/web/")).toEqual({ + orgSlug: "acme", + projectSlug: "web", + }); + }); + + it("does not match if the prefix is wrong", () => { + expect(parseTenantPath("/foo/orgs/acme/projects/web/env/prod")).toBeUndefined(); + }); + + it("handles slugs with hyphens, digits, and mixed case", () => { + expect(parseTenantPath("/orgs/references-6120/projects/hello-world-bN7m/env/dev")).toEqual({ + orgSlug: "references-6120", + projectSlug: "hello-world-bN7m", + envSlug: "dev", + }); + }); +}); + +describe("resolveTenantContextFromPath", () => { + it("returns a TenantContext shaped from the parsed slugs", () => { + expect(resolveTenantContextFromPath("/orgs/acme/projects/web/env/prod")).toEqual({ + orgSlug: "acme", + projectSlug: "web", + envSlug: "prod", + }); + }); + + it("returns an empty context when the path does not match (so loaders can still enrich)", () => { + expect(resolveTenantContextFromPath("/healthcheck")).toEqual({}); + }); +}); + +describe("createTenantContextMiddleware", () => { + function makeReq(path: string) { + return { path } as Parameters>[0]; + } + + it("sets ALS context inside next() when resolver returns a populated context", () => { + const resolver: PathResolver = vi.fn().mockReturnValue(sampleCtx); + const middleware = createTenantContextMiddleware(resolver); + + let observed: TenantContext | undefined; + middleware(makeReq("/orgs/acme/projects/web/env/prod"), {} as never, () => { + observed = tenantContext.get(); + }); + + expect(observed).toEqual(sampleCtx); + expect(resolver).toHaveBeenCalledWith("/orgs/acme/projects/web/env/prod"); + }); + + it("still establishes an empty ALS scope when resolver returns {} (so loaders can enrich)", () => { + const resolver: PathResolver = vi.fn().mockReturnValue({}); + const middleware = createTenantContextMiddleware(resolver); + + let observed: TenantContext | undefined; + middleware(makeReq("/healthcheck"), {} as never, () => { + observed = tenantContext.get(); + tenantContext.enrich({ userId: "usr_1" }); + observed = tenantContext.get(); + }); + + expect(observed).toEqual({ userId: "usr_1" }); + }); + + it("does not leak ALS context after next() returns", () => { + const resolver: PathResolver = vi.fn().mockReturnValue(sampleCtx); + const middleware = createTenantContextMiddleware(resolver); + + middleware(makeReq("/orgs/acme/projects/web/env/prod"), {} as never, () => {}); + + expect(tenantContext.get()).toBeUndefined(); + }); + + it("isolates concurrent requests", async () => { + const ctxA: TenantContext = { ...sampleCtx, orgSlug: "a" }; + const ctxB: TenantContext = { ...sampleCtx, orgSlug: "b" }; + const resolver: PathResolver = vi.fn((path: string) => { + if (path.includes("/a/")) return ctxA; + if (path.includes("/b/")) return ctxB; + return {}; + }); + const middleware = createTenantContextMiddleware(resolver); + + const observe = (path: string, delay: number) => + new Promise((resolve) => { + middleware(makeReq(path), {} as never, async () => { + await new Promise((r) => setTimeout(r, delay)); + resolve(tenantContext.get()); + }); + }); + + const [a, b] = await Promise.all([ + observe("/orgs/a/projects/x/env/y", 10), + observe("/orgs/b/projects/x/env/y", 5), + ]); + expect(a?.orgSlug).toBe("a"); + expect(b?.orgSlug).toBe("b"); + }); +});