Files
triggerdotdev--trigger.dev/apps/webapp/test/tenantContextResolver.test.ts
Daniel Sutton 1015876b98 feat(webapp): user-based Sentry attribution with tenant tags (#3678)
## Summary

Stamp every Sentry event with the signed-in user and the tenant (org /
project / env) the request belongs to, so "Users Impacted" counts
distinct humans and events become filterable per tenant.

**Design after review (current):**

- `user.id = real user cuid` (from `requireUser`). "Users Impacted"
counts humans, not tenants.
- Tenant context (org / project / env slugs, IDs, env type) moves
entirely onto tags: `org_slug`, `project_slug`, `env_slug`, `org_id`,
`project_id`, `project_ref`, `environment_id`, `env_type`, plus
`impersonating` when set.
- Backed by an `AsyncLocalStorage` scope established at the HTTP entry.
Each entry point fills what it knows; loaders enrich the same scope with
what they already have.

**Zero new database queries.** The middleware does a regex match only.
Dashboard loaders that already query Prisma gain a couple of extra
selected columns; nothing new round-trips.

## How it's wired

- **Express middleware (`tenantContextResolver.server.ts`)** — parses
the URL with a regex and always opens an ALS scope. Populates whatever
subset of slugs is present: `/orgs/:o` → just `orgSlug`;
`/orgs/:o/projects/:p` adds `projectSlug`; the full triple adds
`envSlug`. Non-tenant paths get an empty scope so loaders can still
enrich.
- **`_app/route.tsx`** — already calls `requireUser`. Adds
`tenantContext.enrich({ userId: user.id })` for every authenticated
dashboard request. No new query.
- **Env layout loader (`_app.orgs.$o.projects.$p.env.$e/route.tsx`)** —
its existing `prisma.project.findFirst` gains two columns in `select`
(`externalRef`, `organization.id`). After it picks an env, calls
`tenantContext.enrich({ orgId, projectId, projectRef, envId, envType
})`. Same query, +2 columns.
- **API path (`apiBuilder.server.ts`)** — wraps every handler in
`tenantContext.run(tenantContextFromAuthEnvironment(authenticationResult.environment),
…)`. The mapper pulls `userId` from `env.orgMember?.userId` (already
selected by `authIncludeBase` — no schema change). Covers
`createLoaderApiRoute`, `createActionApiRoute`, and
`createMultiMethodApiRoute`.
- **Event processor (`sentryTenantContext.server.ts`)** — registered in
`entry.server.tsx` so it lives in the Remix bundle and shares the same
`tenantContext` ALS instance as the middleware and loaders. Stamps
whatever's present; nothing forced.

## Example events from local verification

| URL | `user.id` | Tags |
|-----|-----------|------|
| `/orgs/:o/projects/:p/env/:e/...` | real user cuid | `org_slug`,
`project_slug`, `env_slug`, `org_id`, `project_id`, `project_ref`,
`environment_id`, `env_type` |
| `/orgs/:o/settings` (non-env-scoped) | real user cuid | `org_slug`
only |
| API request with `orgMember` | `orgMember.userId` | full tenant set |
| API request without `orgMember` | (unset) | full tenant set |

## Trade-offs

1. On env-scoped pages, errors that fire before the env layout loader's
enrich callback runs get slugs + `user.id` but not the tenant IDs /
`env_type`. Realistic errors deep in async work get the full set. (Same
race as before, narrower window now that slugs/`user.id` are populated
up-front by the middleware and `_app` enrich.)
2. API requests where the environment has no `orgMember` get tenant tags
but no `user.id`. Those events still show in the issue but don't
contribute to "Users Impacted".

## Out of scope (deferred)

Background workers (`redis-worker`, `schedule-engine`) and socket
handlers. Those entry points don't set `tenantContext.run` yet — their
events ship without tenant attribution until each is wired in a
follow-up.

## Tests

31 unit tests across 4 files. New tests notably cover:

- `parseTenantPath`: org-only, org+project, and full-triple URL
variants.
- `tenantContext.enrich`: in-place patch, no-op outside `run()`,
concurrent-scope isolation, empty-scope + enrich pattern (for non-tenant
pages).
- `tenantContextFromAuthEnvironment`: with and without `orgMember` —
verifies the API path's `user.id` mapping.
- `addTenantContextToEvent`: empty scope, userId-only, slugs-only, full
enrichment, conditional tag emission, preservation of prior `event.user`
fields.

## Test plan

- [ ] `pnpm run typecheck --filter webapp`
- [ ] `pnpm run test --filter webapp -- test/tenantContext.test.ts
test/sentryTenantContext.test.ts test/tenantContextResolver.test.ts
test/tenantContextFromAuthEnvironment.test.ts`
- [ ] Local manual: with `SENTRY_DSN` set, hit a dashboard URL and an
API route, confirm the captured events carry `user.id` + the expected
tag set in Sentry.
- [ ] After ship: confirm "Users Impacted" on a real Sentry issue
reflects distinct users (not tenants).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:10:41 +01:00

150 lines
5.0 KiB
TypeScript

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<ReturnType<typeof createTenantContextMiddleware>>[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<TenantContext | undefined>((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");
});
});