diff --git a/.changeset/git-branch-utils.md b/.changeset/git-branch-utils.md new file mode 100644 index 00000000000..0fa70693dfc --- /dev/null +++ b/.changeset/git-branch-utils.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add `sanitizeBranchName` and `isValidGitBranchName` exports under `@trigger.dev/core/v3/utils/gitBranch`. These were previously webapp-internal but are now shared with the RBAC fallback's branch-aware authentication path. diff --git a/.changeset/plugin-auth-path.md b/.changeset/plugin-auth-path.md new file mode 100644 index 00000000000..7ce08b71a33 --- /dev/null +++ b/.changeset/plugin-auth-path.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. diff --git a/.github/workflows/e2e-webapp-auth-full.yml b/.github/workflows/e2e-webapp-auth-full.yml new file mode 100644 index 00000000000..a00ca7a4195 --- /dev/null +++ b/.github/workflows/e2e-webapp-auth-full.yml @@ -0,0 +1,120 @@ +name: "πŸ›‘οΈ E2E Tests: Webapp Auth (full)" + +# Comprehensive RBAC auth test suite β€” see TRI-8731. Runs separately from +# the smoke e2e-webapp.yml because it covers every route family with a +# pass/fail matrix and would otherwise dominate per-PR CI time. +# +# Triggered: +# - Manually via workflow_dispatch. +# - Nightly via schedule. +# - On pull requests touching auth-relevant files only (paths filter). + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: "0 4 * * *" # 04:00 UTC daily + pull_request: + paths: + - "apps/webapp/app/services/routeBuilders/**" + - "apps/webapp/app/services/rbac.server.ts" + - "apps/webapp/app/services/apiAuth.server.ts" + - "apps/webapp/app/services/personalAccessToken.server.ts" + - "apps/webapp/app/services/sessionStorage.server.ts" + - "apps/webapp/app/routes/api.v*.**" + - "apps/webapp/app/routes/realtime.v*.**" + - "apps/webapp/test/**/*.e2e.full.test.ts" + - "apps/webapp/test/setup/global-e2e-full-setup.ts" + - "apps/webapp/test/helpers/sharedTestServer.ts" + - "apps/webapp/test/helpers/seedTestSession.ts" + - "apps/webapp/vitest.e2e.full.config.ts" + - "internal-packages/rbac/**" + - "packages/plugins/**" + - ".github/workflows/e2e-webapp-auth-full.yml" + +jobs: + e2eAuthFull: + name: "πŸ›‘οΈ E2E Auth Tests (full)" + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - name: πŸ”§ Disable IPv6 + run: | + sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1 + sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1 + sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1 + + - name: πŸ”§ Configure docker address pool + run: | + CONFIG='{ + "default-address-pools" : [ + { + "base" : "172.17.0.0/12", + "size" : 20 + }, + { + "base" : "192.168.0.0/16", + "size" : 24 + } + ] + }' + mkdir -p /etc/docker + echo "$CONFIG" | sudo tee /etc/docker/daemon.json + + - name: πŸ”§ Restart docker daemon + run: sudo systemctl restart docker + + - name: ⬇️ Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + # Don't leave the GITHUB_TOKEN in .git/config β€” this job + # doesn't need to push and the persisted creds would be + # readable from any subsequent step (zizmor/artipacked). + persist-credentials: false + + - name: βŽ” Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10.33.2 + + - name: βŽ” Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 🐳 Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: 🐳 Skipping DockerHub login (no secrets available) + if: ${{ !env.DOCKERHUB_USERNAME }} + run: echo "DockerHub login skipped because secrets are not available." + + - name: 🐳 Pre-pull testcontainer images + if: ${{ env.DOCKERHUB_USERNAME }} + run: | + docker pull postgres:14 + docker pull redis:7.2 + docker pull testcontainers/ryuk:0.11.0 + + - name: πŸ“₯ Download deps + run: pnpm install --frozen-lockfile + + - name: πŸ“€ Generate Prisma Client + run: pnpm run generate + + - name: πŸ—οΈ Build Webapp + run: pnpm run build --filter webapp + + - name: πŸ›‘οΈ Run Webapp Full Auth E2E Tests + run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default + env: + WEBAPP_TEST_VERBOSE: "1" diff --git a/.server-changes/plugin-auth-path.md b/.server-changes/plugin-auth-path.md new file mode 100644 index 00000000000..c8269125ffc --- /dev/null +++ b/.server-changes/plugin-auth-path.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Webapp now supports a plugin system. Initially consolidates authentication and authorization paths. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index c8cd131d962..b365884de48 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -4,6 +4,7 @@ import { Cog8ToothIcon, CreditCardIcon, LockClosedIcon, + ShieldCheckIcon, UserGroupIcon, } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; @@ -14,6 +15,7 @@ import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; import { + organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, organizationTeamPath, @@ -128,6 +130,18 @@ export function OrganizationSettingsSideMenu({ to={organizationTeamPath(organization)} data-action="team" /> + {isManagedCloud && ( + <> + + + )} : undefined; + // In a Combobox context we wrap the caller's render in ComboboxItem + // so combobox keyboard nav still works. Outside a Combobox we pass + // the render through verbatim β€” without this, callers like + // SelectLinkItem (which uses render to swap in a ) get their + // render prop silently dropped, which is why those rows looked + // clickable but didn't navigate. + const render = combobox + ? + : props.render; const ref = React.useRef(null); const select = Ariakit.useSelectContext(); const selectValue = select?.useState("value"); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 13e9e5dacbd..8e84c2d1903 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1531,6 +1531,9 @@ const EnvironmentSchema = z // Private connections PRIVATE_CONNECTIONS_ENABLED: z.string().optional(), PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(), + + // Force RBAC to not use the plugin + RBAC_FORCE_FALLBACK: BoolEnv.default(false), }) .and(GithubAppEnvSchema) .and(S2EnvSchema) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 04c1df1b41f..b88fc7e11c0 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,6 +1,8 @@ import { type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; +import { logger } from "~/services/logger.server"; +import { rbac } from "~/services/rbac.server"; const tokenValueLength = 40; const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); @@ -86,10 +88,19 @@ export async function inviteMembers({ slug, emails, userId, + rbacRoleId, }: { slug: string; emails: string[]; userId: string; + /** + * Optional RBAC role to attach to the invite. When set, accepted + * invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember + * is created. + * + * `OrgMemberInvite.role` is still set if the plugin isn't installed. + */ + rbacRoleId?: string | null; }) { const org = await prisma.organization.findFirst({ where: { slug, members: { some: { userId } } }, @@ -107,6 +118,7 @@ export async function inviteMembers({ organizationId: org.id, inviterId: userId, role: "MEMBER", + rbacRoleId: rbacRoleId ?? null, } satisfies Prisma.OrgMemberInviteCreateManyInput) ); @@ -163,7 +175,7 @@ export async function acceptInvite({ user: { id: string; email: string }; inviteId: string; }) { - return await prisma.$transaction(async (tx) => { + const result = await prisma.$transaction(async (tx) => { // 1. Delete the invite and get the invite details const invite = await tx.orgMemberInvite.delete({ where: { @@ -207,8 +219,32 @@ export async function acceptInvite({ }, }); - return { remainingInvites, organization: invite.organization }; + return { + remainingInvites, + organization: invite.organization, + inviteRole: invite.role, + rbacRoleId: invite.rbacRoleId, + }; }); + + // If the invite carried an explicit RBAC role. Errors are logged, not fatal. + if (result.rbacRoleId) { + const roleResult = await rbac.setUserRole({ + userId: user.id, + organizationId: result.organization.id, + roleId: result.rbacRoleId, + }); + if (!roleResult.ok) { + logger.error("acceptInvite: skipped RBAC role assignment", { + organizationId: result.organization.id, + userId: user.id, + rbacRoleId: result.rbacRoleId, + reason: roleResult.error, + }); + } + } + + return { remainingInvites: result.remainingInvites, organization: result.organization }; } export async function declineInvite({ diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 0dc634b5ab7..d084bec8add 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server"; import type { Prisma, Project } from "@trigger.dev/database"; import { type Organization, createEnvironment } from "./organization.server"; import { env } from "~/env.server"; -import { projectCreated } from "~/services/platform.v3.server"; +import { projectCreated } from "~/services/projectCreated.server"; export type { Project } from "@trigger.dev/database"; const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20); diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 9db3bb3133b..20fbe223af2 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -3,18 +3,100 @@ import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@tri import { $replica, prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; -import { sanitizeBranchName } from "~/v3/gitBranch"; +import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; export type { RuntimeEnvironment }; +// Prisma include shape that maps cleanly to the slim AuthenticatedEnvironment. +// Use this everywhere we fetch an env that flows to handlers β€” keeps the +// returned shape consistent (and the Decimal coercion in toAuthenticated() +// strips Prisma's Decimal class from the public surface). +const authIncludeBase = { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, +} satisfies Prisma.RuntimeEnvironmentInclude; + +const authIncludeWithParent = { + ...authIncludeBase, + parentEnvironment: { select: { id: true, apiKey: true } }, +} satisfies Prisma.RuntimeEnvironmentInclude; + +type PrismaEnvWithAuth = Prisma.RuntimeEnvironmentGetPayload<{ include: typeof authIncludeBase }>; +type PrismaEnvWithAuthAndParent = Prisma.RuntimeEnvironmentGetPayload<{ + include: typeof authIncludeWithParent; +}>; + +// Coerce a Prisma RuntimeEnvironment payload to the slim +// AuthenticatedEnvironment shape. Drops the columns handlers don't read +// and converts `concurrencyLimitBurstFactor` from Prisma's Decimal to a +// plain number (lossless at this scale). The optional union accepts both +// query shapes β€” with parentEnvironment loaded, or without it. +function toAuthenticated( + env: PrismaEnvWithAuth | PrismaEnvWithAuthAndParent, +): AuthenticatedEnvironment { + return { + id: env.id, + slug: env.slug, + type: env.type, + apiKey: env.apiKey, + organizationId: env.organizationId, + projectId: env.projectId, + orgMemberId: env.orgMemberId, + parentEnvironmentId: env.parentEnvironmentId, + branchName: env.branchName, + archivedAt: env.archivedAt, + paused: env.paused, + shortcode: env.shortcode, + maximumConcurrencyLimit: env.maximumConcurrencyLimit, + // Coerce Prisma's Decimal to a plain number β€” the slim type accepts + // both, but downstream consumers shouldn't have to narrow before + // doing arithmetic. Lossless at this scale (Decimal(4,2)). + concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor.toNumber(), + builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides, + createdAt: env.createdAt, + updatedAt: env.updatedAt, + project: { + id: env.project.id, + slug: env.project.slug, + name: env.project.name, + externalRef: env.project.externalRef, + engine: env.project.engine, + deletedAt: env.project.deletedAt, + defaultWorkerGroupId: env.project.defaultWorkerGroupId, + organizationId: env.project.organizationId, + builderProjectId: env.project.builderProjectId, + }, + organization: { + id: env.organization.id, + slug: env.organization.slug, + title: env.organization.title, + streamBasinName: env.organization.streamBasinName, + maximumConcurrencyLimit: env.organization.maximumConcurrencyLimit, + runsEnabled: env.organization.runsEnabled, + maximumDevQueueSize: env.organization.maximumDevQueueSize, + maximumDeployedQueueSize: env.organization.maximumDeployedQueueSize, + featureFlags: env.organization.featureFlags, + apiRateLimiterConfig: env.organization.apiRateLimiterConfig, + batchRateLimitConfig: env.organization.batchRateLimitConfig, + batchQueueConcurrencyConfig: env.organization.batchQueueConcurrencyConfig, + }, + orgMember: env.orgMember, + parentEnvironment: "parentEnvironment" in env ? env.parentEnvironment : null, + }; +} + export async function findEnvironmentByApiKey( apiKey: string, branchName: string | undefined ): Promise { const include = { - project: true, - organization: true, - orgMember: true, + ...authIncludeBase, childEnvironments: branchName ? { where: { @@ -67,20 +149,20 @@ export async function findEnvironmentByApiKey( const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { - return { + return toAuthenticated({ ...childEnvironment, apiKey: environment.apiKey, orgMember: environment.orgMember, organization: environment.organization, project: environment.project, - }; + }); } //A branch was specified but no child environment was found return null; } - return environment; + return toAuthenticated(environment); } /** @deprecated We don't use public api keys anymore */ @@ -92,50 +174,29 @@ export async function findEnvironmentByPublicApiKey( where: { pkApiKey: apiKey, }, - include: { - project: true, - organization: true, - orgMember: true, - }, + include: authIncludeBase, }); - //don't return deleted projects - if (environment?.project.deletedAt !== null) { + if (!environment || environment.project.deletedAt !== null) { return null; } - return environment; + return toAuthenticated(environment); } -export async function findEnvironmentById( - id: string -): Promise< - | (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null }) - | null -> { +export async function findEnvironmentById(id: string): Promise { const environment = await $replica.runtimeEnvironment.findFirst({ where: { id, }, - include: { - project: true, - organization: true, - orgMember: true, - parentEnvironment: { - select: { - id: true, - apiKey: true, - }, - }, - }, + include: authIncludeWithParent, }); - //don't return deleted projects - if (environment?.project.deletedAt !== null) { + if (!environment || environment.project.deletedAt !== null) { return null; } - return environment; + return toAuthenticated(environment); } export async function findEnvironmentBySlug( @@ -143,7 +204,7 @@ export async function findEnvironmentBySlug( envSlug: string, userId: string ): Promise { - return $replica.runtimeEnvironment.findFirst({ + const environment = await $replica.runtimeEnvironment.findFirst({ where: { projectId: projectId, slug: envSlug, @@ -161,12 +222,9 @@ export async function findEnvironmentBySlug( }, ], }, - include: { - project: true, - organization: true, - orgMember: true, - }, + include: authIncludeBase, }); + return environment ? toAuthenticated(environment) : null; } export async function findEnvironmentFromRun( @@ -178,24 +236,16 @@ export async function findEnvironmentFromRun( id: runId, }, include: { - runtimeEnvironment: { - include: { - project: true, - organization: true, - orgMember: true, - }, - }, + runtimeEnvironment: { include: authIncludeBase }, }, }); - - if (!taskRun) { - return null; - } - - return taskRun?.runtimeEnvironment; + return taskRun?.runtimeEnvironment ? toAuthenticated(taskRun.runtimeEnvironment) : null; } -export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) { +export async function createNewSession( + environment: Pick, + ipAddress: string +) { const session = await prisma.runtimeEnvironmentSession.create({ data: { environmentId: environment.id, diff --git a/apps/webapp/app/presenters/TeamPresenter.server.ts b/apps/webapp/app/presenters/TeamPresenter.server.ts index 8b84a65a67c..f2e5da61a87 100644 --- a/apps/webapp/app/presenters/TeamPresenter.server.ts +++ b/apps/webapp/app/presenters/TeamPresenter.server.ts @@ -1,4 +1,5 @@ import { getTeamMembersAndInvites } from "~/models/member.server"; +import { rbac } from "~/services/rbac.server"; import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server"; import { BasePresenter } from "./v3/basePresenter.server"; @@ -13,11 +14,30 @@ export class TeamPresenter extends BasePresenter { return; } - const [baseLimit, currentPlan, plans] = await Promise.all([ - getLimit(organizationId, "teamMembers", 100_000_000), - getCurrentPlan(organizationId), - getPlans(), - ]); + const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] = + await Promise.all([ + getLimit(organizationId, "teamMembers", 100_000_000), + getCurrentPlan(organizationId), + getPlans(), + // RBAC role catalogue (system roles + any org-defined custom + // roles). The default fallback returns []; an installed plugin + // may return the seeded system roles plus any custom roles. + rbac.allRoles(organizationId), + // Plan-gated subset β€” the Teams page disables dropdown options not + // in this set. Server-side enforcement is independent (setUserRole + // rejects a plan-gated assignment regardless of UI state). + rbac.getAssignableRoleIds(organizationId), + // Per-member current role in a single round-trip. + rbac.getUserRoles( + result.members.map((m) => m.user.id), + organizationId + ), + ]); + + const memberRoles = result.members.map((m) => ({ + userId: m.user.id, + role: memberRoleMap.get(m.user.id) ?? null, + })); const canPurchaseSeats = currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true; @@ -38,6 +58,9 @@ export class TeamPresenter extends BasePresenter { seatPricing, maxSeatQuota, planSeatLimit, + roles, + assignableRoleIds, + memberRoles, }; } } diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 254ec18d1c0..aa6e15e0fa5 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -149,10 +149,10 @@ type ApiRunListSearchParams = z.infer; export class ApiRunListPresenter extends BasePresenter { public async call( - project: Project, + project: Pick, searchParams: ApiRunListSearchParams, apiVersion: API_VERSIONS, - environment?: RuntimeEnvironment + environment?: Pick ) { return this.trace("call", async (span) => { const options: RunListOptions = { diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts index 10201094376..5bcdee6b0a9 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts @@ -47,7 +47,10 @@ export class EnvironmentQueuePresenter extends BasePresenter { running, queued, concurrencyLimit: environment.maximumConcurrencyLimit, - burstFactor: environment.concurrencyLimitBurstFactor.toNumber(), + burstFactor: + typeof environment.concurrencyLimitBurstFactor === "number" + ? environment.concurrencyLimitBurstFactor + : environment.concurrencyLimitBurstFactor.toNumber(), runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled, queueSizeLimit, }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 44990abaa6e..f77c19ffbdd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -25,13 +25,15 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { inviteMembers } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; -import { scheduleEmail } from "~/services/email.server"; +import { scheduleEmail } from "~/services/scheduleEmail.server"; +import { rbac } from "~/services/rbac.server"; import { requireUserId } from "~/services/session.server"; import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; @@ -63,9 +65,77 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Not Found", { status: 404 }); } - return typedjson(result); + // Inviter's own role drives the "below their level" filter on the + // dropdown. Plus assignable role IDs already encode the org's plan + // tier β€” the intersection is what we offer. + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId: organization.id }), + rbac.getAssignableRoleIds(organization.id), + rbac.systemRoles(organization.id), + ]); + + // Build the dropdown's offerable set server-side: roles that are + // (a) assignable on the current plan AND (b) at or below the + // inviter's own level. The client just renders these β€” it doesn't + // need to know about the system-role catalogue or the ladder. + const assignableSet = new Set(assignableRoleIds); + const offerableRoleIds = systemRoles + ? result.roles + .filter( + (r) => + assignableSet.has(r.id) && + isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id) + ) + .map((r) => r.id) + : []; + + return typedjson({ ...result, offerableRoleIds }); }; +// Sentinel for "no RBAC role attached to invite" β€” the runtime +// fallback will derive a role from the legacy OrgMember.role write at +// accept time. Used when the org has no RBAC plugin installed (the +// dropdown is hidden) or as a defensive default. +const NO_RBAC_ROLE = "__no_rbac_role__"; + +// An inviter can only assign a role at or below their own. The +// plugin's systemRoles array is in canonical order (highest authority +// first), so array index drives the ladder β€” earlier index = higher +// rank. Plan-tier filtering happens separately via assignableRoleIds; +// the ladder is the absolute hierarchy. Custom roles aren't in the +// table and are refused (TRI-8747's follow-up will handle them). +type LadderRole = { id: string }; + +function buildRoleLevel(roles: ReadonlyArray): Record { + const level: Record = {}; + roles.forEach((r, i) => { + // Top of the array = highest level. Subtract from length so larger + // numbers always mean "more authority" β€” no off-by-one when a role + // is added or removed. + level[r.id] = roles.length - i; + }); + return level; +} + +function isAtOrBelow( + roles: ReadonlyArray, + inviterRoleId: string | null, + invitedRoleId: string +): boolean { + // No RBAC role on inviter (e.g. the runtime fallback couldn't derive + // one) β†’ fall back to the legacy OrgMember.role check the calling + // code already enforces. Allow the invite to proceed; the action + // would have already failed earlier if the inviter wasn't allowed + // to invite at all. + if (!inviterRoleId) return true; + const level = buildRoleLevel(roles); + const inviter = level[inviterRoleId]; + const invited = level[invitedRoleId]; + // Custom roles aren't in the level table β€” refuse. + if (inviter === undefined || invited === undefined) return false; + return invited <= inviter; +} + const schema = z.object({ emails: z.preprocess((i) => { if (typeof i === "string") return [i]; @@ -80,6 +150,7 @@ const schema = z.object({ return [""]; }, z.string().email().array().nonempty("At least one email is required")), + rbacRoleId: z.string().optional(), }); export const action: ActionFunction = async ({ request, params }) => { @@ -94,11 +165,62 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } + // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown + // role β†’ don't pass one through; the runtime fallback handles it. + // Validation: the chosen role must be in the org's assignable set + // (plan-tier) and at or below the inviter's own level. + let resolvedRbacRoleId: string | null = null; + const submittedRbacRoleId = submission.value.rbacRoleId; + if ( + submittedRbacRoleId && + submittedRbacRoleId !== NO_RBAC_ROLE + ) { + const org = await $replica.organization.findFirst({ + where: { slug: organizationSlug }, + select: { id: true }, + }); + if (!org) { + return json({ errors: { body: "Organization not found" } }, { status: 404 }); + } + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId: org.id }), + rbac.getAssignableRoleIds(org.id), + rbac.systemRoles(org.id), + ]); + if (!systemRoles) { + // No plugin installed but the form somehow submitted a role id β€” + // ignore it (fall through to legacy behaviour rather than 400). + resolvedRbacRoleId = null; + } else { + const assignable = new Set(assignableRoleIds); + if (!assignable.has(submittedRbacRoleId)) { + return json( + { errors: { body: "You can't invite someone with this role on your current plan" } }, + { status: 400 } + ); + } + if ( + !isAtOrBelow( + systemRoles, + inviterRole?.id ?? null, + submittedRbacRoleId + ) + ) { + return json( + { errors: { body: "You can only invite members at or below your own role" } }, + { status: 403 } + ); + } + resolvedRbacRoleId = submittedRbacRoleId; + } + } + try { const invites = await inviteMembers({ slug: organizationSlug, emails: submission.value.emails, userId, + rbacRoleId: resolvedRbacRoleId, }); for (const invite of invites) { @@ -128,12 +250,35 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } = - useTypedLoaderData(); + const { + limits, + canPurchaseSeats, + seatPricing, + extraSeats, + maxSeatQuota, + planSeatLimit, + roles, + offerableRoleIds, + } = useTypedLoaderData(); const [total, setTotal] = useState(limits.used); const organization = useOrganization(); const lastSubmission = useActionData(); + // The loader filtered the catalogue to roles this inviter can + // actually assign (plan tier Γ— strict-below-my-level). With no plugin + // installed, offerableRoleIds is [] and the picker hides entirely. + const offerableSet = new Set(offerableRoleIds); + const offerable = roles.filter((r) => offerableSet.has(r.id)); + const showRolePicker = offerable.length > 0; + + // Default to the lowest-tier offered role (the loader returns roles + // in its allRoles order, which the plugin emits Ownerβ†’Member; the + // last entry is the most restrictive). + const defaultRoleId = showRolePicker + ? offerable[offerable.length - 1].id + : NO_RBAC_ROLE; + const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId); + const [form, { emails }] = useForm({ id: "invite-members", // TODO: type this @@ -232,6 +377,36 @@ export default function Page() { ))} + {showRolePicker ? ( + + + + + defaultValue={defaultRoleId} + items={offerable} + variant="tertiary/medium" + dropdownIcon + text={(v) => + offerable.find((r) => r.id === v)?.name ?? "Pick a role" + } + setValue={(next) => { + if (typeof next === "string") setSelectedRoleId(next); + }} + > + {(items) => + items.map((role) => ( + + {role.name} + + )) + } + + + Invitees join with this role. They can be promoted later + from the Team page. + + + ) : null} limits.limit}> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx new file mode 100644 index 00000000000..bd4770d1919 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -0,0 +1,363 @@ +import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; +import { useState } from "react"; +import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Button } from "~/components/primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Header3 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { cn } from "~/utils/cn"; +import { $replica } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { rbac } from "~/services/rbac.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { TextLink } from "~/components/primitives/TextLink"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Roles | Trigger.dev`, + }, + ]; +}; + +const Params = z.object({ + organizationSlug: z.string(), +}); + +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ + where: { slug }, + select: { id: true }, + }); + return org?.id ?? null; +} + +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + authorization: { action: "read", resource: { type: "members" } }, + }, + async ({ context }) => { + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } + + const [roles, assignableRoleIds, allPermissions, systemRoles] = await Promise.all([ + rbac.allRoles(orgId), + rbac.getAssignableRoleIds(orgId), + rbac.allPermissions(orgId), + rbac.systemRoles(orgId), + ]); + + return typedjson({ + roles, + assignableRoleIds, + allPermissions, + systemRoles, + }); + } +); + +type LoaderData = UseDataFunctionReturn; +type LoaderRole = LoaderData["roles"][number]; +type LoaderPermission = LoaderData["allPermissions"][number]; +type RolePermission = LoaderRole["permissions"][number]; + +// Permissions are bucketed by `permission.group` from the plugin. +// Section order = first-seen order in `allPermissions()`. Permissions +// without a group fall into "Other" at the bottom. +const FALLBACK_GROUP = "Other"; + +export default function Page() { + const { roles, assignableRoleIds, allPermissions, systemRoles } = + useTypedLoaderData(); + const organization = useOrganization(); + const plan = useCurrentPlan(); + const planCode = plan?.v3Subscription?.plan?.code; + const isEnterprise = planCode === "enterprise"; + + // Map role-id β†’ role for fast cell lookup. Each role's permissions are + // already the expanded `effectivePermissions` output (system roles + // populated server-side; custom roles too) so cells just filter that + // list by permission name. + const rolesById = new Map(roles.map((r) => [r.id, r])); + const assignable = new Set(assignableRoleIds); + + // Column ordering follows the plugin's canonical systemRoles order + // (highest authority first), then any custom roles in the order + // rbac.allRoles returned them. systemRoles is null when no plugin is + // installed; fall through to whatever order rbac.allRoles returns. + // Each entry's `available` flag reflects plan-tier eligibility β€” we + // render unavailable system roles too, but PlanBadge tags them so + // customers see the comparison and know what an upgrade unlocks. + const systemRoleOrder = systemRoles ?? []; + const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id)); + const systemColumns = systemRoleOrder.flatMap((meta) => { + const role = rolesById.get(meta.id); + return role ? [{ role, fallbackName: meta.name }] : []; + }); + const customColumns = roles + .filter((r) => !systemRoleIdSet.has(r.id)) + .map((role) => ({ role, fallbackName: role.name })); + const columns = [...systemColumns, ...customColumns]; + + const grouped = groupPermissions(allPermissions); + + return ( + + + + {!isEnterprise ? : null} + + +
+
+ + Roles control what each team member can do in {organization.title}. + Compare what each role grants below; assign a role to a team member from the{" "} + Team page. + +
+
+ {columns.length === 0 ? ( + + ) : ( + + + + Permission + {columns.map(({ role }) => ( + +
+ {role.name} + +
+
+ ))} + Description +
+
+ + {grouped.length === 0 ? ( + + + No permissions to display. + + + ) : ( + grouped.flatMap(({ group, permissions }) => [ + + + + {group} + + + , + ...permissions.map((permission) => ( + + + {permission.name} + + {columns.map(({ role }) => ( + + + + ))} + + + {permission.description || ( + β€” + )} + + + + )), + ]) + )} + +
+ )} +
+
+
+
+ ); +} + +function EmptyState() { + return ( +
+ No roles available on this plan. + + Upgrade to Pro to unlock RBAC. + +
+ ); +} + +function PlanBadge({ + roleId, + assignable, + systemRoleIdSet, +}: { + roleId: string; + assignable: ReadonlySet; + systemRoleIdSet: ReadonlySet; +}) { + // Roles the org's plan doesn't permit get a small upgrade-tier hint + // in the column header. The cell rendering is identical regardless + // β€” the comparison value is still useful even on Free/Hobby. + if (assignable.has(roleId)) return null; + // System roles render as "Pro" (the gating tier where they unlock β€” + // Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles + // render as "Enterprise" β€” only Enterprise plans can create or assign + // them. + if (systemRoleIdSet.has(roleId)) { + return Pro; + } + return Enterprise; +} + +// Render a single (role Γ— permission) cell. Filters the role's +// effectivePermissions list to entries matching this permission name +// and emits an icon + optional condition badge based on the rules. +function RoleCell({ + permissionName, + rolePermissions, +}: { + permissionName: string; + rolePermissions: RolePermission[]; +}) { + const matching = rolePermissions.filter((p) => p.name === permissionName); + + if (matching.length === 0) { + // No rule matches β€” the role denies this permission by omission. + return ( + + + + ); + } + + const allowed = matching.filter((p) => !p.inverted); + const denied = matching.filter((p) => p.inverted); + + // Only inverted rules apply β€” the role explicitly denies this + // permission. Render as βœ— in error colour. + if (allowed.length === 0) { + return ( + + + + ); + } + + // At least one allow rule applies. If there's a conditional cannot + // rule, replace the βœ“ with just the condition label so the user sees + // the restriction without a misleading tick. Plain unconditional + // allow keeps the βœ“. + const conditionalDeny = denied.find((p) => p.conditions); + if (conditionalDeny?.conditions) { + return ( + {conditionLabel(conditionalDeny.conditions)} + ); + } + return ( + + + + ); +} + +// Render a CASL conditions object into a tier badge label. Only +// `envType` is recognised today (the catalogue's only allowed condition); +// extending this requires adding a new branch when ALLOWED_CONDITIONS +// grows. +function conditionLabel(conditions: Record): string { + if (typeof conditions.envType === "string") { + if (conditions.envType === "PRODUCTION") return "Non-prod only"; + return `Non-${conditions.envType.toLowerCase()} only`; + } + return JSON.stringify(conditions); +} + +function groupPermissions( + permissions: LoaderPermission[] +): { group: string; permissions: LoaderPermission[] }[] { + // Insertion-ordered map: groups appear in the order their first + // permission was seen. Plugins that want a specific section order + // just emit permissions in that order from `allPermissions()`. + const buckets = new Map(); + for (const permission of permissions) { + const group = permission.group ?? FALLBACK_GROUP; + const list = buckets.get(group) ?? []; + list.push(permission); + buckets.set(group, list); + } + return Array.from(buckets, ([group, permissions]) => ({ group, permissions })); +} + +function CreateRoleUpsell() { + const [open, setOpen] = useState(false); + return ( + + + + + + Custom roles are an Enterprise feature +
+ + Define your own roles with bespoke permission sets β€” perfect for "Member, but no + production deploys" or a vendor/contractor role. Available on the Enterprise plan. + + + Get in touch and we'll walk you through the Enterprise plan and how custom roles fit + your team. + +
+
+ + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index dc71bc5585f..d805cf0d057 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -9,7 +9,7 @@ import { useFetcher, useNavigation, } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import { useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -41,24 +41,27 @@ import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; +import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { cn } from "~/utils/cn"; import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { removeTeamMember } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; -import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { cn } from "~/utils/cn"; +import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { inviteTeamMemberPath, + organizationRolesPath, organizationTeamPath, resendInvitePath, revokeInvitePath, v3BillingPath, } from "~/utils/pathBuilder"; -import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; @@ -74,31 +77,51 @@ const Params = z.object({ organizationSlug: z.string(), }); -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = Params.parse(params); - - const organization = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, +// Resolve slug β†’ orgId in the dashboardLoader's context callback so the +// rbac.authenticateSession call gets a real organizationId. The result +// is cached for the duration of the request and reused by the handler +// below (we re-find by slug there to get a typed value β€” the context +// only sees the loosely typed return type). +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ + where: { slug }, select: { id: true }, }); + return org?.id ?? null; +} - if (!organization) { - throw new Response("Not Found", { status: 404 }); - } +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + authorization: { action: "read", resource: { type: "members" } }, + }, + async ({ user, ability, context }) => { + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } - const presenter = new TeamPresenter(); - const result = await presenter.call({ - userId, - organizationId: organization.id, - }); + const presenter = new TeamPresenter(); + const result = await presenter.call({ + userId: user.id, + organizationId: orgId, + }); - if (!result) { - throw new Response("Not Found", { status: 404 }); - } + if (!result) { + throw new Response("Not Found", { status: 404 }); + } - return typedjson(result); -}; + // Pre-compute manage authority server-side so the UI gating matches + // the action gating (the action enforces it independently). + const canManageMembers = ability.can("manage", { type: "members" }); + + return typedjson({ ...result, canManageMembers }); + } +); const schema = z.object({ memberId: z.string(), @@ -111,89 +134,128 @@ const PurchaseSchema = z.discriminatedUnion("action", [ }), z.object({ action: z.literal("quota-increase"), - amount: z.coerce - .number() - .int("Must be a whole number") - .min(1, "Amount must be greater than 0"), + amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"), }), ]); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = params; - invariant(organizationSlug, "organizationSlug not found"); +const SetRoleSchema = z.object({ + userId: z.string(), + roleId: z.string(), +}); - const formData = await request.formData(); - const formType = formData.get("_formType"); +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + // No top-level authorization β€” different intents have different + // requirements (set-role needs manage:members; remove/leave is + // gated by the existing model layer; purchase-seats by the + // SetSeatsAddOnService). Per-intent ability checks happen inside. + }, + async ({ user, ability, request, params, context }) => { + const userId = user.id; + const { organizationSlug } = params; + invariant(organizationSlug, "organizationSlug not found"); - if (formType === "purchase-seats") { - const org = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, - select: { id: true }, - }); + const formData = await request.formData(); + const formType = formData.get("_formType"); - if (!org) { - return json({ ok: false, error: "Organization not found" } as const); + if (formType === "set-role") { + if (!ability.can("manage", { type: "members" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + const orgId = context.organizationId; + if (!orgId) { + return json({ ok: false, error: "Organization not found" } as const, { status: 404 }); + } + const submission = parse(formData, { schema: SetRoleSchema }); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + const result = await rbac.setUserRole({ + userId: submission.value.userId, + organizationId: orgId, + roleId: submission.value.roleId, + }); + if (!result.ok) { + return json({ ok: false, error: result.error } as const, { status: 400 }); + } + return json({ ok: true } as const); } - const submission = parse(formData, { schema: PurchaseSchema }); + if (formType === "purchase-seats") { + const org = await $replica.organization.findFirst({ + where: { slug: organizationSlug }, + select: { id: true }, + }); - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + if (!org) { + return json({ ok: false, error: "Organization not found" } as const); + } - const service = new SetSeatsAddOnService(); - const [error, result] = await tryCatch( - service.call({ - userId, - organizationId: org.id, - action: submission.value.action, - amount: submission.value.amount, - }) - ); + const submission = parse(formData, { schema: PurchaseSchema }); - if (error) { - submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; - return json(submission); - } + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - if (!result.success) { - submission.error.amount = [result.error]; - return json(submission); - } + const service = new SetSeatsAddOnService(); + const [error, result] = await tryCatch( + service.call({ + userId, + organizationId: org.id, + action: submission.value.action, + amount: submission.value.amount, + }) + ); - return json({ ok: true } as const); - } + if (error) { + submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; + return json(submission); + } - const submission = parse(formData, { schema }); + if (!result.success) { + submission.error.amount = [result.error]; + return json(submission); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + return json({ ok: true } as const); + } - try { - const deletedMember = await removeTeamMember({ - userId, - memberId: submission.value.memberId, - slug: organizationSlug, - }); + const submission = parse(formData, { schema }); - if (deletedMember.userId === userId) { - return redirectWithSuccessMessage("/", request, `You left the organization`); + if (!submission.value || submission.intent !== "submit") { + return json(submission); } - return redirectWithSuccessMessage( - organizationTeamPath(deletedMember.organization), - request, - `Removed ${deletedMember.user.name ?? "member"} from team` - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); + try { + const deletedMember = await removeTeamMember({ + userId, + memberId: submission.value.memberId, + slug: organizationSlug, + }); + + if (deletedMember.userId === userId) { + return redirectWithSuccessMessage("/", request, `You left the organization`); + } + + return redirectWithSuccessMessage( + organizationTeamPath(deletedMember.organization), + request, + `Removed ${deletedMember.user.name ?? "member"} from team` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); + } } -}; +); type Member = UseDataFunctionReturn["members"][number]; type Invite = UseDataFunctionReturn["invites"][number]; +type Role = UseDataFunctionReturn["roles"][number]; export default function Page() { const { @@ -205,7 +267,16 @@ export default function Page() { seatPricing, maxSeatQuota, planSeatLimit, + roles, + assignableRoleIds, + memberRoles, + canManageMembers, } = useTypedLoaderData(); + // Build a userId β†’ roleId map so the dropdown's defaultValue matches + // each member's current assignment without re-querying. + const memberRoleByUserId = new Map( + memberRoles.flatMap((m) => (m.role ? [[m.userId, m.role.id]] : [])) + ); const user = useUser(); const organization = useOrganization(); @@ -242,10 +313,31 @@ export default function Page() { ))} - {requiresUpgrade ? ( + {!canManageMembers ? ( + // Gate the invite affordance on manage:members. The action + // route enforces this independently β€” hiding it here just + // avoids dead UI for non-managers. + + Invite a team member + + } + content="You don't have permission to invite team members" + disableHoverableContent + /> + ) : requiresUpgrade ? ( + Invite a team member } @@ -291,34 +383,57 @@ export default function Page() { )} - Active team members -
    +
    + Active team members + {roles.length > 0 ? ( + + View all role permissions β†’ + + ) : null} +
    +
    {members.map((member) => ( -
  • - -
    - - {member.user.name}{" "} - {member.user.id === user.id && ( - (You) - )} - - {member.user.email} +
    +
    + +
    + + {member.user.name}{" "} + {member.user.id === user.id && ( + (You) + )} + + {member.user.email} +
    -
    + +
    -
  • +
    ))} -
+ @@ -387,10 +502,12 @@ function LeaveRemoveButton({ userId, member, memberCount, + canManageMembers, }: { userId: string; member: Member; memberCount: number; + canManageMembers: boolean; }) { const organization = useOrganization(); @@ -409,7 +526,8 @@ function LeaveRemoveButton({ ); } - //you leave the team + //you leave the team β€” leaving is always permitted regardless of + //manage:members; non-managers can still leave on their own. return ( + Remove from team + + } + disableHoverableContent + content="You don't have permission to remove team members" + /> + ); + } return ( (); + const assignable = new Set(assignableRoleIds); + // With no RBAC plugin installed, the loader returns no roles β€” + // render nothing rather than an empty dropdown. + if (roles.length === 0) return null; + + const isSubmitting = fetcher.state === "submitting"; + const error = + fetcher.data && "error" in fetcher.data && fetcher.data.error ? fetcher.data.error : null; + + const picker = ( + + ); + + return ( +
+ {canManageMembers ? ( + picker + ) : ( + // Disabled + {showRolePicker && }
@@ -265,6 +376,37 @@ function CreatePersonalAccessToken() { {tokenName.error} + {showRolePicker && ( + + + + value={selectedRoleId} + setValue={(v) => setSelectedRoleId(v)} + items={roles} + variant="tertiary/small" + dropdownIcon + text={(v) => roles.find((r) => r.id === v)?.name ?? "Select a role"} + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + + + The token can act with up to this role. Your current role in each org is the + actual ceiling β€” the token never grants more than you have. + + + )} + diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index aafb8180026..9c2c012f6e8 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -1,7 +1,5 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -22,7 +20,7 @@ import { import { useUser } from "~/hooks/useUser"; import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -32,30 +30,34 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) { + throw new Error(searchParams.error); + } + const result = await adminGetUsers(user.id, searchParams.params.getAll()); - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) { - throw new Error(searchParams.error); + return typedjson(result); } - const result = await adminGetUsers(userId, searchParams.params.getAll()); - - return typedjson(result); -}; +); const FormSchema = z.object({ id: z.string() }); -export async function action({ request }: ActionFunctionArgs) { - if (request.method.toLowerCase() !== "post") { - return new Response("Method not allowed", { status: 405 }); - } +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + if (request.method.toLowerCase() !== "post") { + return new Response("Method not allowed", { status: 405 }); + } - const payload = Object.fromEntries(await request.formData()); - const { id } = FormSchema.parse(payload); + const payload = Object.fromEntries(await request.formData()); + const { id } = FormSchema.parse(payload); - return redirectWithImpersonation(request, id, "/"); -} + return redirectWithImpersonation(request, id, "/"); + } +); export default function AdminDashboardRoute() { const user = useUser(); diff --git a/apps/webapp/app/routes/admin.back-office._index.tsx b/apps/webapp/app/routes/admin.back-office._index.tsx index 15e6f699b9a..e2226aebb4a 100644 --- a/apps/webapp/app/routes/admin.back-office._index.tsx +++ b/apps/webapp/app/routes/admin.back-office._index.tsx @@ -1,17 +1,15 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson } from "remix-typedjson"; +import { typedjson } from "remix-typedjson"; import { LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -export async function loader({ request }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + return typedjson({}); } - return typedjson({}); -} +); export default function BackOfficeIndex() { return ( diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx index 211a5a4fd2e..1fe3e872168 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -1,5 +1,4 @@ import { Form, useNavigation, useSearchParams } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; import { redirect, typedjson, useTypedActionData, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -19,7 +18,7 @@ import { } from "~/services/authorizationRateLimitMiddleware.server"; import { logger } from "~/services/logger.server"; import { type Duration } from "~/services/rateLimiter.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; const SAVED_QUERY_KEY = "saved"; const SAVED_QUERY_VALUE = "1"; @@ -98,39 +97,38 @@ function describeRateLimit( }; } -export async function loader({ request, params }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - const orgId = params.orgId; - if (!orgId) { - throw new Response(null, { status: 404 }); - } +const ParamsSchema = z.object({ + orgId: z.string(), +}); - const org = await prisma.organization.findFirst({ - where: { id: orgId }, - select: { - id: true, - slug: true, - title: true, - createdAt: true, - apiRateLimiterConfig: true, - }, - }); - - if (!org) { - throw new Response(null, { status: 404 }); - } +export const loader = dashboardLoader( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params }) => { + const { orgId } = params; + + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { + id: true, + slug: true, + title: true, + createdAt: true, + apiRateLimiterConfig: true, + }, + }); + + if (!org) { + throw new Response(null, { status: 404 }); + } - const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig); + const effective = resolveEffectiveRateLimit(org.apiRateLimiterConfig); - return typedjson({ - org, - effective, - }); -} + return typedjson({ + org, + effective, + }); + } +); const SetRateLimitSchema = z.object({ intent: z.literal("set-rate-limit"), @@ -144,64 +142,59 @@ const SetRateLimitSchema = z.object({ maxTokens: z.coerce.number().int().min(1), }); -export async function action({ request, params }: ActionFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - const orgId = params.orgId; - if (!orgId) { - throw new Response(null, { status: 404 }); - } - - const formData = await request.formData(); - const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); - if (!submission.success) { - return typedjson( - { errors: submission.error.flatten().fieldErrors }, - { status: 400 } - ); - } +export const action = dashboardAction( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ user, params, request }) => { + const { orgId } = params; + + const formData = await request.formData(); + const submission = SetRateLimitSchema.safeParse(Object.fromEntries(formData)); + if (!submission.success) { + return typedjson( + { errors: submission.error.flatten().fieldErrors }, + { status: 400 } + ); + } - const existing = await prisma.organization.findFirst({ - where: { id: orgId }, - select: { apiRateLimiterConfig: true }, - }); - if (!existing) { - throw new Response(null, { status: 404 }); - } + const existing = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { apiRateLimiterConfig: true }, + }); + if (!existing) { + throw new Response(null, { status: 404 }); + } - const built = RateLimitTokenBucketConfig.safeParse({ - type: "tokenBucket", - refillRate: submission.data.refillRate, - interval: submission.data.interval, - maxTokens: submission.data.maxTokens, - }); - if (!built.success) { - return typedjson( - { errors: built.error.flatten().fieldErrors }, - { status: 400 } + const built = RateLimitTokenBucketConfig.safeParse({ + type: "tokenBucket", + refillRate: submission.data.refillRate, + interval: submission.data.interval, + maxTokens: submission.data.maxTokens, + }); + if (!built.success) { + return typedjson( + { errors: built.error.flatten().fieldErrors }, + { status: 400 } + ); + } + const next = built.data; + + await prisma.organization.update({ + where: { id: orgId }, + data: { apiRateLimiterConfig: next as any }, + }); + + logger.info("admin.backOffice.rateLimit", { + adminUserId: user.id, + orgId, + previous: existing.apiRateLimiterConfig, + next, + }); + + return redirect( + `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}` ); } - const next = built.data; - - await prisma.organization.update({ - where: { id: orgId }, - data: { apiRateLimiterConfig: next as any }, - }); - - logger.info("admin.backOffice.rateLimit", { - adminUserId: user.id, - orgId, - previous: existing.apiRateLimiterConfig, - next, - }); - - return redirect( - `/admin/back-office/orgs/${orgId}?${SAVED_QUERY_KEY}=${SAVED_QUERY_VALUE}` - ); -} +); export default function BackOfficeOrgPage() { const { org, effective } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.back-office.tsx b/apps/webapp/app/routes/admin.back-office.tsx index 026fc13fdc5..3ec9e99b2ca 100644 --- a/apps/webapp/app/routes/admin.back-office.tsx +++ b/apps/webapp/app/routes/admin.back-office.tsx @@ -1,15 +1,13 @@ import { Outlet } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson } from "remix-typedjson"; -import { requireUser } from "~/services/session.server"; +import { typedjson } from "remix-typedjson"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -export async function loader({ request }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + return typedjson({}); } - return typedjson({}); -} +); export default function BackOfficeLayout() { return ( diff --git a/apps/webapp/app/routes/admin.concurrency.tsx b/apps/webapp/app/routes/admin.concurrency.tsx index a24f7debb9d..630bc100b0b 100644 --- a/apps/webapp/app/routes/admin.concurrency.tsx +++ b/apps/webapp/app/routes/admin.concurrency.tsx @@ -1,23 +1,19 @@ import { InformationCircleIcon } from "@heroicons/react/20/solid"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { Header1 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true); + const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false); + return typedjson({ deployedConcurrency, devConcurrency }); } - - const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true); - const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false); - - return typedjson({ deployedConcurrency, devConcurrency }); -}; +); export default function AdminDashboardRoute() { const { deployedConcurrency, devConcurrency } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 4066e6a4d9b..02faa7add91 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -1,14 +1,16 @@ import { useFetcher } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; import stableStringify from "json-stable-stringify"; import { json } from "@remix-run/server-runtime"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { LockClosedIcon } from "@heroicons/react/20/solid"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { requireUser } from "~/services/session.server"; +import { + dashboardAction, + dashboardLoader, +} from "~/services/routeBuilders/dashboardBuilder"; import { FEATURE_FLAG, GLOBAL_LOCKED_FLAGS, @@ -38,53 +40,48 @@ import { type WorkerGroup, } from "~/components/admin/FlagControls"; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - const [globalFlags, workerGroups] = await Promise.all([ - getGlobalFlags(), - prisma.workerInstanceGroup.findMany({ - select: { id: true, name: true }, - orderBy: { name: "asc" }, - }), - ]); - const controlTypes = getAllFlagControlTypes(); - - // Resolve env-based defaults for locked flags - const resolvedDefaults: Record = { - [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE, - }; - - // Look up worker group name if the flag is set - const workerGroupId = (globalFlags as Record)?.[ - FEATURE_FLAG.defaultWorkerInstanceGroupId - ]; - const workerGroupName = - typeof workerGroupId === "string" - ? workerGroups.find((wg) => wg.id === workerGroupId)?.name - : undefined; - - const { isManagedCloud } = featuresForRequest(request); - - return typedjson({ - globalFlags, - controlTypes, - resolvedDefaults, - workerGroupName, - workerGroups, - isManagedCloud, - }); -}; - -export const action = async ({ request }: ActionFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - throw new Response("Unauthorized", { status: 403 }); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const [globalFlags, workerGroups] = await Promise.all([ + getGlobalFlags(), + prisma.workerInstanceGroup.findMany({ + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + ]); + const controlTypes = getAllFlagControlTypes(); + + // Resolve env-based defaults for locked flags + const resolvedDefaults: Record = { + [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE, + }; + + // Look up worker group name if the flag is set + const workerGroupId = (globalFlags as Record)?.[ + FEATURE_FLAG.defaultWorkerInstanceGroupId + ]; + const workerGroupName = + typeof workerGroupId === "string" + ? workerGroups.find((wg) => wg.id === workerGroupId)?.name + : undefined; + + const { isManagedCloud } = featuresForRequest(request); + + return typedjson({ + globalFlags, + controlTypes, + resolvedDefaults, + workerGroupName, + workerGroups, + isManagedCloud, + }); } +); +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { let body: unknown; try { body = await request.json(); @@ -156,7 +153,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { ]); return json({ success: true }); -}; + } +); export default function AdminFeatureFlagsRoute() { const { diff --git a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx index 7b51067dd0c..e90752fb28d 100644 --- a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx +++ b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx @@ -1,5 +1,4 @@ import { Form, useActionData, useNavigate } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -8,34 +7,37 @@ import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Input } from "~/components/primitives/Input"; import { Paragraph } from "~/components/primitives/Paragraph"; import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const model = await prisma.llmModel.findUnique({ - where: { friendlyId: params.modelId }, - include: { - pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, - }, - }); - - if (!model) throw new Response("Model not found", { status: 404 }); - - // Convert Prisma Decimal to plain numbers for serialization - const serialized = { - ...model, - pricingTiers: model.pricingTiers.map((t) => ({ - ...t, - prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), - })), - }; - - return typedjson({ model: serialized }); -}; +const ParamsSchema = z.object({ + modelId: z.string(), +}); + +export const loader = dashboardLoader( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params }) => { + const model = await prisma.llmModel.findUnique({ + where: { friendlyId: params.modelId }, + include: { + pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, + }, + }); + + if (!model) throw new Response("Model not found", { status: 404 }); + + // Convert Prisma Decimal to plain numbers for serialization + const serialized = { + ...model, + pricingTiers: model.pricingTiers.map((t) => ({ + ...t, + prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), + })), + }; + + return typedjson({ model: serialized }); + } +); const SaveSchema = z.object({ modelName: z.string().min(1), @@ -49,100 +51,99 @@ const SaveSchema = z.object({ isHidden: z.string().optional(), }); -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const friendlyId = params.modelId!; - const existing = await prisma.llmModel.findUnique({ where: { friendlyId } }); - if (!existing) throw new Response("Model not found", { status: 404 }); - const modelId = existing.id; - - const formData = await request.formData(); - const _action = formData.get("_action"); - - if (_action === "delete") { - await prisma.llmModel.delete({ where: { id: modelId } }); - await llmPricingRegistry?.reload(); - return redirect("/admin/llm-models"); - } - - if (_action === "save") { - const raw = Object.fromEntries(formData); - const parsed = SaveSchema.safeParse(raw); - - if (!parsed.success) { - return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); +export const action = dashboardAction( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params, request }) => { + const friendlyId = params.modelId; + const existing = await prisma.llmModel.findUnique({ where: { friendlyId } }); + if (!existing) throw new Response("Model not found", { status: 404 }); + const modelId = existing.id; + + const formData = await request.formData(); + const _action = formData.get("_action"); + + if (_action === "delete") { + await prisma.llmModel.delete({ where: { id: modelId } }); + await llmPricingRegistry?.reload(); + return redirect("/admin/llm-models"); } - const { modelName, matchPattern, pricingTiersJson } = parsed.data; - - // Validate regex β€” strip (?i) POSIX flag since our registry handles it - try { - const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; - new RegExp(testPattern); - } catch { - return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); - } - - // Parse tiers - let pricingTiers: Array<{ - name: string; - isDefault: boolean; - priority: number; - conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; - prices: Record; - }>; - try { - pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; - } catch { - return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); - } - - // Update model - const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; - await prisma.llmModel.update({ - where: { id: modelId }, - data: { - modelName, - matchPattern, - provider: provider || null, - description: description || null, - contextWindow: contextWindow ? parseInt(contextWindow) || null : null, - maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, - capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], - isHidden: isHidden === "on", - }, - }); - - // Replace tiers - await prisma.llmPricingTier.deleteMany({ where: { modelId } }); - for (const tier of pricingTiers) { - await prisma.llmPricingTier.create({ + if (_action === "save") { + const raw = Object.fromEntries(formData); + const parsed = SaveSchema.safeParse(raw); + + if (!parsed.success) { + return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); + } + + const { modelName, matchPattern, pricingTiersJson } = parsed.data; + + // Validate regex β€” strip (?i) POSIX flag since our registry handles it + try { + const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; + new RegExp(testPattern); + } catch { + return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); + } + + // Parse tiers + let pricingTiers: Array<{ + name: string; + isDefault: boolean; + priority: number; + conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; + prices: Record; + }>; + try { + pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; + } catch { + return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); + } + + // Update model + const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; + await prisma.llmModel.update({ + where: { id: modelId }, data: { - modelId, - name: tier.name, - isDefault: tier.isDefault, - priority: tier.priority, - conditions: tier.conditions, - prices: { - create: Object.entries(tier.prices).map(([usageType, price]) => ({ - modelId, - usageType, - price, - })), - }, + modelName, + matchPattern, + provider: provider || null, + description: description || null, + contextWindow: contextWindow ? parseInt(contextWindow) || null : null, + maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, + capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], + isHidden: isHidden === "on", }, }); + + // Replace tiers + await prisma.llmPricingTier.deleteMany({ where: { modelId } }); + for (const tier of pricingTiers) { + await prisma.llmPricingTier.create({ + data: { + modelId, + name: tier.name, + isDefault: tier.isDefault, + priority: tier.priority, + conditions: tier.conditions, + prices: { + create: Object.entries(tier.prices).map(([usageType, price]) => ({ + modelId, + usageType, + price, + })), + }, + }, + }); + } + + await llmPricingRegistry?.reload(); + return typedjson({ success: true }); } - await llmPricingRegistry?.reload(); - return typedjson({ success: true }); + return typedjson({ error: "Unknown action" }, { status: 400 }); } - - return typedjson({ error: "Unknown action" }, { status: 400 }); -} +); export default function AdminLlmModelDetailRoute() { const { model } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.llm-models._index.tsx b/apps/webapp/app/routes/admin.llm-models._index.tsx index ea2eff72541..585cbb4637b 100644 --- a/apps/webapp/app/routes/admin.llm-models._index.tsx +++ b/apps/webapp/app/routes/admin.llm-models._index.tsx @@ -1,7 +1,5 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form, useFetcher, Link } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -18,7 +16,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { seedLlmPricing, syncLlmCatalog } from "@internal/llm-model-catalog"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; @@ -30,121 +28,119 @@ const SearchParams = z.object({ search: z.string().optional(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) throw new Error(searchParams.error); + const { page: rawPage, search } = searchParams.params.getAll(); + const page = rawPage ?? 1; + + const where = { + projectId: null as string | null, + ...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}), + }; + + const [rawModels, total] = await Promise.all([ + prisma.llmModel.findMany({ + where, + include: { + pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, + }, + orderBy: { modelName: "asc" }, + skip: (page - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }), + prisma.llmModel.count({ where }), + ]); + + // Convert Prisma Decimal to plain numbers for serialization + const models = rawModels.map((m) => ({ + ...m, + pricingTiers: m.pricingTiers.map((t) => ({ + ...t, + prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), + })), + })); - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) throw new Error(searchParams.error); - const { page: rawPage, search } = searchParams.params.getAll(); - const page = rawPage ?? 1; - - const where = { - projectId: null as string | null, - ...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}), - }; - - const [rawModels, total] = await Promise.all([ - prisma.llmModel.findMany({ - where, - include: { - pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, - }, - orderBy: { modelName: "asc" }, - skip: (page - 1) * PAGE_SIZE, - take: PAGE_SIZE, - }), - prisma.llmModel.count({ where }), - ]); - - // Convert Prisma Decimal to plain numbers for serialization - const models = rawModels.map((m) => ({ - ...m, - pricingTiers: m.pricingTiers.map((t) => ({ - ...t, - prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), - })), - })); - - return typedjson({ - models, - total, - page, - pageCount: Math.ceil(total / PAGE_SIZE), - filters: { search }, - }); -}; - -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const formData = await request.formData(); - const _action = formData.get("_action"); - - if (_action === "seed") { - console.log("[admin] seed action started"); - const result = await seedLlmPricing(prisma); - console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`); - await llmPricingRegistry?.reload(); - console.log("[admin] registry reloaded after seed"); return typedjson({ - success: true, - message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`, + models, + total, + page, + pageCount: Math.ceil(total / PAGE_SIZE), + filters: { search }, }); } +); + +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const formData = await request.formData(); + const _action = formData.get("_action"); + + if (_action === "seed") { + console.log("[admin] seed action started"); + const result = await seedLlmPricing(prisma); + console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`); + await llmPricingRegistry?.reload(); + console.log("[admin] registry reloaded after seed"); + return typedjson({ + success: true, + message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`, + }); + } - if (_action === "sync") { - console.log("[admin] sync catalog action started"); - const result = await syncLlmCatalog(prisma); - console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`); - await llmPricingRegistry?.reload(); - console.log("[admin] registry reloaded after sync"); - return typedjson({ - success: true, - message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`, - }); - } - - if (_action === "reload") { - console.log("[admin] reload action started"); - await llmPricingRegistry?.reload(); - console.log("[admin] registry reloaded"); - return typedjson({ success: true, message: "Registry reloaded" }); - } - - if (_action === "test") { - const modelString = formData.get("modelString"); - if (typeof modelString !== "string" || !modelString) { - return typedjson({ testResult: null }); + if (_action === "sync") { + console.log("[admin] sync catalog action started"); + const result = await syncLlmCatalog(prisma); + console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`); + await llmPricingRegistry?.reload(); + console.log("[admin] registry reloaded after sync"); + return typedjson({ + success: true, + message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`, + }); } - // Use the registry's match() which handles prefix stripping automatically - const matched = llmPricingRegistry?.match(modelString) ?? null; + if (_action === "reload") { + console.log("[admin] reload action started"); + await llmPricingRegistry?.reload(); + console.log("[admin] registry reloaded"); + return typedjson({ success: true, message: "Registry reloaded" }); + } - return typedjson({ - testResult: { - modelString, - match: matched - ? { friendlyId: matched.friendlyId, modelName: matched.modelName } - : null, - }, - }); - } + if (_action === "test") { + const modelString = formData.get("modelString"); + if (typeof modelString !== "string" || !modelString) { + return typedjson({ testResult: null }); + } + + // Use the registry's match() which handles prefix stripping automatically + const matched = llmPricingRegistry?.match(modelString) ?? null; + + return typedjson({ + testResult: { + modelString, + match: matched + ? { friendlyId: matched.friendlyId, modelName: matched.modelName } + : null, + }, + }); + } - if (_action === "delete") { - const modelId = formData.get("modelId"); - if (typeof modelId === "string") { - await prisma.llmModel.delete({ where: { id: modelId } }); - await llmPricingRegistry?.reload(); + if (_action === "delete") { + const modelId = formData.get("modelId"); + if (typeof modelId === "string") { + await prisma.llmModel.delete({ where: { id: modelId } }); + await llmPricingRegistry?.reload(); + } + return typedjson({ success: true }); } - return typedjson({ success: true }); - } - return typedjson({ error: "Unknown action" }, { status: 400 }); -} + return typedjson({ error: "Unknown action" }, { status: 400 }); + } +); export default function AdminLlmModelsRoute() { const { models, filters, page, pageCount, total } = diff --git a/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx b/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx index 78cb1c4fc91..3c63ce09fc4 100644 --- a/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx +++ b/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx @@ -1,39 +1,40 @@ import { useState } from "react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { getMissingModelSamples, type MissingModelSample, } from "~/services/admin/missingLlmModels.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +const ParamsSchema = z.object({ + model: z.string(), +}); - // Model name is URL-encoded in the URL param - const modelName = decodeURIComponent(params.model ?? ""); - if (!modelName) throw new Response("Missing model param", { status: 400 }); +export const loader = dashboardLoader( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params, request }) => { + // Model name is URL-encoded in the URL param + const modelName = decodeURIComponent(params.model); + if (!modelName) throw new Response("Missing model param", { status: 400 }); - const url = new URL(request.url); - const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); + const url = new URL(request.url); + const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); - let samples: MissingModelSample[] = []; - let error: string | undefined; + let samples: MissingModelSample[] = []; + let error: string | undefined; - try { - samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 }); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to query ClickHouse"; - } + try { + samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 }); + } catch (e) { + error = e instanceof Error ? e.message : "Failed to query ClickHouse"; + } - return typedjson({ modelName, samples, lookbackHours, error }); -}; + return typedjson({ modelName, samples, lookbackHours, error }); + } +); export default function AdminMissingModelDetailRoute() { const { modelName, samples, lookbackHours, error } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.llm-models.missing._index.tsx b/apps/webapp/app/routes/admin.llm-models.missing._index.tsx index fd933cd22e9..7cacb727f9c 100644 --- a/apps/webapp/app/routes/admin.llm-models.missing._index.tsx +++ b/apps/webapp/app/routes/admin.llm-models.missing._index.tsx @@ -1,6 +1,4 @@ import { useSearchParams } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { LinkButton } from "~/components/primitives/Buttons"; @@ -14,8 +12,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { getMissingLlmModels } from "~/services/admin/missingLlmModels.server"; const LOOKBACK_OPTIONS = [ @@ -30,25 +27,24 @@ const SearchParams = z.object({ lookbackHours: z.coerce.number().optional(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const url = new URL(request.url); + const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); - const url = new URL(request.url); - const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); + let models: Awaited> = []; + let error: string | undefined; - let models: Awaited> = []; - let error: string | undefined; + try { + models = await getMissingLlmModels({ lookbackHours }); + } catch (e) { + error = e instanceof Error ? e.message : "Failed to query ClickHouse"; + } - try { - models = await getMissingLlmModels({ lookbackHours }); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to query ClickHouse"; + return typedjson({ models, lookbackHours, error }); } - - return typedjson({ models, lookbackHours, error }); -}; +); export default function AdminLlmModelsMissingRoute() { const { models, lookbackHours, error } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.llm-models.new.tsx b/apps/webapp/app/routes/admin.llm-models.new.tsx index 7f18bf5826a..ab9c7881e2c 100644 --- a/apps/webapp/app/routes/admin.llm-models.new.tsx +++ b/apps/webapp/app/routes/admin.llm-models.new.tsx @@ -1,5 +1,4 @@ import { Form, useActionData, useSearchParams } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; @@ -7,16 +6,16 @@ import { useState } from "react"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Input } from "~/components/primitives/Input"; import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - return typedjson({}); -}; +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + return typedjson({}); + } +); const CreateSchema = z.object({ modelName: z.string().min(1), @@ -30,83 +29,82 @@ const CreateSchema = z.object({ isHidden: z.string().optional(), }); -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const formData = await request.formData(); - const raw = Object.fromEntries(formData); - console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500)); - const parsed = CreateSchema.safeParse(raw); +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const formData = await request.formData(); + const raw = Object.fromEntries(formData); + console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500)); + const parsed = CreateSchema.safeParse(raw); + + if (!parsed.success) { + console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues)); + return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); + } - if (!parsed.success) { - console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues)); - return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); - } + const { modelName, matchPattern, pricingTiersJson } = parsed.data; - const { modelName, matchPattern, pricingTiersJson } = parsed.data; + // Validate regex β€” strip (?i) POSIX flag since our registry handles it + try { + const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; + new RegExp(testPattern); + } catch { + return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); + } - // Validate regex β€” strip (?i) POSIX flag since our registry handles it - try { - const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; - new RegExp(testPattern); - } catch { - return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); - } + let pricingTiers: Array<{ + name: string; + isDefault: boolean; + priority: number; + conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; + prices: Record; + }>; + try { + pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; + } catch { + return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); + } - let pricingTiers: Array<{ - name: string; - isDefault: boolean; - priority: number; - conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; - prices: Record; - }>; - try { - pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; - } catch { - return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); - } + const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; - const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; - - const model = await prisma.llmModel.create({ - data: { - friendlyId: generateFriendlyId("llm_model"), - modelName, - matchPattern, - source: "admin", - provider: provider || null, - description: description || null, - contextWindow: contextWindow ? parseInt(contextWindow) || null : null, - maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, - capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], - isHidden: isHidden === "on", - }, - }); - - for (const tier of pricingTiers) { - await prisma.llmPricingTier.create({ + const model = await prisma.llmModel.create({ data: { - modelId: model.id, - name: tier.name, - isDefault: tier.isDefault, - priority: tier.priority, - conditions: tier.conditions, - prices: { - create: Object.entries(tier.prices).map(([usageType, price]) => ({ - modelId: model.id, - usageType, - price, - })), - }, + friendlyId: generateFriendlyId("llm_model"), + modelName, + matchPattern, + source: "admin", + provider: provider || null, + description: description || null, + contextWindow: contextWindow ? parseInt(contextWindow) || null : null, + maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, + capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], + isHidden: isHidden === "on", }, }); - } - await llmPricingRegistry?.reload(); - return redirect(`/admin/llm-models/${model.friendlyId}`); -} + for (const tier of pricingTiers) { + await prisma.llmPricingTier.create({ + data: { + modelId: model.id, + name: tier.name, + isDefault: tier.isDefault, + priority: tier.priority, + conditions: tier.conditions, + prices: { + create: Object.entries(tier.prices).map(([usageType, price]) => ({ + modelId: model.id, + usageType, + price, + })), + }, + }, + }); + } + + await llmPricingRegistry?.reload(); + return redirect(`/admin/llm-models/${model.friendlyId}`); + } +); export default function AdminLlmModelNewRoute() { const actionData = useActionData<{ error?: string; details?: unknown[] }>(); diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index 179ab23c3ee..543367d5571 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -1,7 +1,5 @@ import { ChevronRightIcon, TrashIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { useFetcher, useSearchParams } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { useEffect, useRef, useState, useLayoutEffect } from "react"; import ReactMarkdown from "react-markdown"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -36,8 +34,6 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; import { archivePlatformNotification, createPlatformNotification, @@ -46,6 +42,7 @@ import { publishNowPlatformNotification, updatePlatformNotification, } from "~/services/platformNotifications.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { cn } from "~/utils/cn"; @@ -59,51 +56,49 @@ const SearchParams = z.object({ hideInactive: z.coerce.boolean().optional(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) throw new Error(searchParams.error); + const { page: rawPage, hideInactive } = searchParams.params.getAll(); + const page = rawPage ?? 1; - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) throw new Error(searchParams.error); - const { page: rawPage, hideInactive } = searchParams.params.getAll(); - const page = rawPage ?? 1; + const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false }); - const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false }); - - return typedjson({ ...data, userId }); -}; + return typedjson({ ...data, userId: user.id }); + } +); -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const formData = await request.formData(); + const _action = formData.get("_action"); - const formData = await request.formData(); - const _action = formData.get("_action"); + if (_action === "create" || _action === "create-preview") { + return handleCreateAction(formData, user.id, _action === "create-preview"); + } - if (_action === "create" || _action === "create-preview") { - return handleCreateAction(formData, userId, _action === "create-preview"); - } + if (_action === "archive") { + return handleArchiveAction(formData); + } - if (_action === "archive") { - return handleArchiveAction(formData); - } + if (_action === "delete") { + return handleDeleteAction(formData); + } - if (_action === "delete") { - return handleDeleteAction(formData); - } + if (_action === "publish-now") { + return handlePublishNowAction(formData); + } - if (_action === "publish-now") { - return handlePublishNowAction(formData); - } + if (_action === "edit") { + return handleEditAction(formData); + } - if (_action === "edit") { - return handleEditAction(formData); + return typedjson({ error: "Unknown action" }, { status: 400 }); } - - return typedjson({ error: "Unknown action" }, { status: 400 }); -} +); function parseNotificationFormData(formData: FormData) { const surface = formData.get("surface") as string; diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index 6d16ab99c9d..8441d4d19da 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -1,7 +1,6 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { useState } from "react"; import { z } from "zod"; import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog"; @@ -20,7 +19,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { adminGetOrganizations } from "~/models/admin.server"; -import { requireUser, requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -30,20 +29,18 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) { + throw new Error(searchParams.error); + } + const result = await adminGetOrganizations(user.id, searchParams.params.getAll()); - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) { - throw new Error(searchParams.error); + return typedjson(result); } - const result = await adminGetOrganizations(user.id, searchParams.params.getAll()); - - return typedjson(result); -}; +); export default function AdminDashboardRoute() { const { organizations, filters, page, pageCount } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index 61431398220..236c7f0580c 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -1,18 +1,13 @@ import { Outlet } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson } from "remix-typedjson"; +import { typedjson } from "remix-typedjson"; import { LinkButton } from "~/components/primitives/Buttons"; import { Tabs } from "~/components/primitives/Tabs"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -export async function loader({ request }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - return typedjson({ user }); -} +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user }) => typedjson({ user }) +); export default function Page() { return ( diff --git a/apps/webapp/app/routes/api.v1.batches.$batchId.ts b/apps/webapp/app/routes/api.v1.batches.$batchId.ts index d852385b4b6..a48db2ee407 100644 --- a/apps/webapp/app/routes/api.v1.batches.$batchId.ts +++ b/apps/webapp/app/routes/api.v1.batches.$batchId.ts @@ -25,8 +25,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (batch) => ({ batch: batch.friendlyId }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (batch) => ({ type: "batch", id: batch.friendlyId }), }, }, async ({ resource: batch }) => { diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 0190ba123d5..369ef0191d8 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -72,8 +72,7 @@ export const loader = createLoaderApiRoute( corsStrategy: "none", authorization: { action: "read", - resource: () => ({ deployments: "list" }), - superScopes: ["read:deployments", "read:all", "admin"], + resource: () => ({ type: "deployments", id: "list" }), }, findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts index 557a67409de..f9c5ac0b68c 100644 --- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts +++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts @@ -21,8 +21,7 @@ export const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "write", - resource: () => ({}), - superScopes: ["write:runs", "admin"], + resource: () => ({ type: "runs" }), }, }, async ({ params, body, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts index 295bcb5caee..5e952aa7ce0 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts @@ -1,5 +1,6 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { $replica } from "~/db.server"; import { findProjectByRef } from "~/models/project.server"; import { ApiRunListPresenter, @@ -16,6 +17,20 @@ export const loader = createLoaderPATApiRoute( params: ParamsSchema, searchParams: ApiRunListSearchParams, corsStrategy: "all", + // Resolve projectRef β†’ org so the PAT plugin can ground its + // role-floor calculation. We deliberately don't filter by user + // membership here β€” that's the plugin's job (`authenticatePat` + // checks OrgMember in the target org and rejects if the user + // isn't a member). Keeps the contract clean: context is "what + // org does this URL target?" and auth is "is this user allowed?" + context: async (params) => { + const project = await $replica.project.findFirst({ + where: { externalRef: params.projectRef }, + select: { organizationId: true }, + }); + return project ? { organizationId: project.organizationId } : {}; + }, + authorization: { action: "read", resource: () => ({ type: "runs" }) }, }, async ({ searchParams, params, authentication, apiVersion }) => { const project = await findProjectByRef(params.projectRef, authentication.userId); diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts index 1203682793a..99601b5d668 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts @@ -22,8 +22,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "update", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, }, async ({ body, params, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts index 3ddf7b78416..2a00ceac15c 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts @@ -40,8 +40,7 @@ const { action, loader } = createMultiMethodApiRoute({ corsStrategy: "all", authorization: { action: "update", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, methods: { POST: { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts index 6040fdb46e6..795e4a6c68f 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts @@ -22,8 +22,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "update", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, }, async ({ body, params, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.ts index 32ea1525c14..0d101ae6122 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.ts @@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (_resource, params) => ({ prompts: params.slug }), - superScopes: ["read:prompts", "admin"], + resource: (_resource, params) => ({ type: "prompts", id: params.slug }), }, }, async ({ searchParams, resource: prompt }) => { @@ -98,8 +97,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["read:prompts", "admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, }, async ({ body, params, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts index c40b3e62dbf..49f90a98c84 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts @@ -27,8 +27,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (_resource, params) => ({ prompts: params.slug }), - superScopes: ["read:prompts", "admin"], + resource: (_resource, params) => ({ type: "prompts", id: params.slug }), }, }, async ({ resource: prompt }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts._index.ts b/apps/webapp/app/routes/api.v1.prompts._index.ts index ccbc0ec38d0..e4ef5f9702e 100644 --- a/apps/webapp/app/routes/api.v1.prompts._index.ts +++ b/apps/webapp/app/routes/api.v1.prompts._index.ts @@ -10,8 +10,7 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, authorization: { action: "read", - resource: () => ({ prompts: "all" }), - superScopes: ["read:prompts", "admin"], + resource: () => ({ type: "prompts", id: "all" }), }, }, async ({ authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.query.dashboards._index.ts b/apps/webapp/app/routes/api.v1.query.dashboards._index.ts index fdc4dbc3852..2bc9e3b3016 100644 --- a/apps/webapp/app/routes/api.v1.query.dashboards._index.ts +++ b/apps/webapp/app/routes/api.v1.query.dashboards._index.ts @@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, authorization: { action: "read", - resource: () => ({ query: "dashboards" }), - superScopes: ["read:query", "read:all", "admin"], + resource: () => ({ type: "query", id: "dashboards" }), }, }, async () => { diff --git a/apps/webapp/app/routes/api.v1.query.schema.ts b/apps/webapp/app/routes/api.v1.query.schema.ts index aa4762af6f8..3e95d16818d 100644 --- a/apps/webapp/app/routes/api.v1.query.schema.ts +++ b/apps/webapp/app/routes/api.v1.query.schema.ts @@ -47,8 +47,7 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, authorization: { action: "read", - resource: () => ({ query: "schema" }), - superScopes: ["read:query", "read:all", "admin"], + resource: () => ({ type: "query", id: "schema" }), }, }, async () => { diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index 05d92e9726a..3fb6b04ec78 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -1,7 +1,10 @@ import { json } from "@remix-run/server-runtime"; import { QueryError } from "@internal/clickhouse"; import { z } from "zod"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; import { executeQuery, type QueryScope } from "~/services/queryService.server"; import { logger } from "~/services/logger.server"; import { rowsToCSV } from "~/utils/dataExport"; @@ -34,11 +37,16 @@ const { action, loader } = createActionApiRoute( findResource: async () => 1, authorization: { action: "read", + // A multi-table query reads from every detected table. Wrap with + // everyResource so a JWT scoped to one table can't pass auth for + // a query that also reads tables it isn't scoped to (would be the + // same OR-loophole the batch trigger route had pre-fix). resource: (_, __, ___, body) => { const tables = detectTables(body.query); - return { query: tables.length > 0 ? tables : "all" }; + return tables.length > 0 + ? everyResource(tables.map((id) => ({ type: "query", id }))) + : { type: "query", id: "all" }; }, - superScopes: ["read:query", "read:all", "admin"], }, }, async ({ body, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts index ac96c9ddb81..6e48288e958 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts @@ -1,7 +1,10 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; @@ -21,13 +24,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts index 7c093efd960..a123b1522b7 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts @@ -3,7 +3,10 @@ import { BatchId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; import { extractAISpanData } from "~/components/runs/v3/ai"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; @@ -28,13 +31,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batchId) { + resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) }); + } + return anyResource(resources); + }, }, }, async ({ params, resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts index cc35836bfe6..aba85259fbc 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts @@ -2,7 +2,10 @@ import { json } from "@remix-run/server-runtime"; import { BatchId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; @@ -26,13 +29,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batchId) { + resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) }); + } + return anyResource(resources); + }, }, }, async ({ resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.ts b/apps/webapp/app/routes/api.v1.runs.ts index b5191ee2591..59bad5bf741 100644 --- a/apps/webapp/app/routes/api.v1.runs.ts +++ b/apps/webapp/app/routes/api.v1.runs.ts @@ -4,7 +4,10 @@ import { ApiRunListSearchParams, } from "~/presenters/v3/ApiRunListPresenter.server"; import { logger } from "~/services/logger.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; export const loader = createLoaderApiRoute( { @@ -13,8 +16,13 @@ export const loader = createLoaderApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (_, __, searchParams) => { + const taskFilter = searchParams["filter[taskIdentifier]"] ?? []; + return anyResource([ + { type: "runs" }, + ...taskFilter.map((id) => ({ type: "tasks", id })), + ]); + }, }, findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.close.ts b/apps/webapp/app/routes/api.v1.sessions.$session.close.ts index 16d8a6d93d1..15c2e8dc6bd 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$session.close.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$session.close.ts @@ -25,8 +25,7 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", authorization: { action: "admin", - resource: (params) => ({ sessions: params.session }), - superScopes: ["admin:sessions", "admin:all", "admin"], + resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ authentication, params, body }) => { diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts b/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts index cdc9c9e8dc7..7c5718aeae3 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts @@ -8,7 +8,10 @@ import { $replica, prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { swapSessionRun } from "~/services/realtime/sessionRunManager.server"; import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createActionApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ session: z.string(), @@ -42,15 +45,18 @@ const { action, loader } = createActionApiRoute( resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session), authorization: { action: "write", + // Multi-key: the session is addressable by URL param, friendlyId, + // and externalId β€” a JWT scoped to any of them grants access. + // Type-level `write:sessions` (no id) also matches; `write:all` / + // `admin` bypass via the JWT ability's wildcard branches. resource: (params, _, __, ___, session) => { const ids = new Set([params.session]); if (session) { ids.add(session.friendlyId); if (session.externalId) ids.add(session.externalId); } - return { sessions: [...ids] }; + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, - superScopes: ["write:sessions", "write:all", "admin"], }, }, async ({ authentication, params, body, resource: session }) => { diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.ts b/apps/webapp/app/routes/api.v1.sessions.$session.ts index 800ee32b99b..9b6fb339989 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$session.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$session.ts @@ -11,6 +11,7 @@ import { serializeSessionWithFriendlyRunId, } from "~/services/realtime/sessions.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -29,8 +30,17 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (session) => ({ sessions: [session.friendlyId, session.externalId ?? ""] }), - superScopes: ["read:sessions", "read:all", "admin"], + // Multi-key: a session is addressable by both friendlyId and (when + // set) externalId. A JWT scoped to either id grants access; type- + // level `read:sessions` (no id) matches both elements; `read:all` + // / `admin` bypass via the JWT ability's wildcard branches. + resource: (session) => + session.externalId + ? anyResource([ + { type: "sessions", id: session.friendlyId }, + { type: "sessions", id: session.externalId }, + ]) + : { type: "sessions", id: session.friendlyId }, }, }, async ({ resource: session }) => { @@ -50,8 +60,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "admin", - resource: (params) => ({ sessions: params.session }), - superScopes: ["admin:sessions", "admin:all", "admin"], + resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ authentication, params, body }) => { diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index eafb0f7a20c..591a9fe5319 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -20,6 +20,7 @@ import { import { serializeSession } from "~/services/realtime/sessions.server"; import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -37,8 +38,21 @@ export const loader = createLoaderApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }), - superScopes: ["read:sessions", "read:all", "admin"], + // Multi-key resource preserves the pre-RBAC superScope semantics: + // - Per-task scoping via `read:tasks:` matches a task element + // - Type-level `read:sessions` (the old superScope) matches the + // sessions element (collection-level β€” no id) + // - `read:all` / `admin` bypass via the JWT ability's wildcard branches + // The taskIdentifier filter accepts a string or an array; expand to + // one resource per task id so any per-task-scoped JWT among them + // grants access (the array gets OR semantics). + resource: (_, __, searchParams) => { + const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? []; + return anyResource([ + ...taskFilter.map((id) => ({ type: "tasks" as const, id })), + { type: "sessions" as const }, + ]); + }, }, findResource: async () => 1, }, @@ -113,21 +127,20 @@ const { action } = createActionApiRoute( // Per-task scoping via `body.taskIdentifier` (action-route resource // callbacks receive the parsed body as the 4th arg β€” see // `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo` - // can only create sessions whose `taskIdentifier` is `"foo"`. Broad - // callers (cli-v3 MCP, customer servers wrapping their own auth) - // hold the `write:sessions` super-scope and bypass the per-task - // check entirely. + // can only create sessions whose `taskIdentifier` is `"foo"`. // - // Note: the auth check is OR across resource types, so listing both - // `sessions` and `tasks` here would let a `write:sessions`-scoped - // JWT pass for *any* task β€” defeating the per-task narrowing. Keep - // it task-only and let the super-scope path handle session-level - // wildcard access. + // Multi-key resource: pre-RBAC this route had a `superScopes: + // ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent + // is the `{ type: "sessions" }` element below β€” a `write:sessions` + // JWT (no id) matches it directly, deliberately bypassing the + // per-task check exactly as before. `admin` / `write:all` bypass + // via the JWT ability's wildcard branches. action: "write", - resource: (_params, _searchParams, _headers, body) => ({ - tasks: body.taskIdentifier, - }), - superScopes: ["write:sessions", "admin"], + resource: (_params, _searchParams, _headers, body) => + anyResource([ + { type: "tasks", id: body.taskIdentifier }, + { type: "sessions" }, + ]), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 5811fc67709..c069103d368 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -51,8 +51,7 @@ const { action, loader } = createActionApiRoute( maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE, authorization: { action: "trigger", - resource: (params) => ({ tasks: params.taskId }), - superScopes: ["write:tasks", "admin"], + resource: (params) => ({ type: "tasks", id: params.taskId }), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index 50760b79a6d..16b3ef16062 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -7,7 +7,10 @@ import { import { env } from "~/env.server"; import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { @@ -30,10 +33,17 @@ const { action, loader } = createActionApiRoute( maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE, authorization: { action: "batchTrigger", - resource: (_, __, ___, body) => ({ - tasks: Array.from(new Set(body.items.map((i) => i.task))), - }), - superScopes: ["write:tasks", "admin"], + // Each item in the batch is a distinct task β€” every one must be + // authorized, not just any one of them. `everyResource` flips + // the auth check to AND semantics so a JWT scoped to taskA can't + // submit a batch that also includes taskB / taskC. + resource: (_, __, ___, body) => + everyResource( + Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({ + type: "tasks", + id, + })) + ), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts index e8a7f046edb..d86feff7b91 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts @@ -45,6 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) { orgMember: true, parentEnvironment: { select: { + id: true, apiKey: true, }, }, diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts index 133b6bc55fb..4a3e5f960c6 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts @@ -23,8 +23,7 @@ const { action, loader } = createActionApiRoute( allowJWT: true, authorization: { action: "write", - resource: (params) => ({ waitpoints: params.waitpointFriendlyId }), - superScopes: ["write:waitpoints", "admin"], + resource: (params) => ({ type: "waitpoints", id: params.waitpointFriendlyId }), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v2.batches.$batchId.ts b/apps/webapp/app/routes/api.v2.batches.$batchId.ts index c89dbbaf312..218eb433559 100644 --- a/apps/webapp/app/routes/api.v2.batches.$batchId.ts +++ b/apps/webapp/app/routes/api.v2.batches.$batchId.ts @@ -25,8 +25,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (batch) => ({ batch: batch.friendlyId }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (batch) => ({ type: "batch", id: batch.friendlyId }), }, }, async ({ resource: batch }) => { diff --git a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts index a05af273d8d..a636ca0cc1d 100644 --- a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts +++ b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts @@ -15,8 +15,7 @@ const { action } = createActionApiRoute( corsStrategy: "none", authorization: { action: "write", - resource: (params) => ({ runs: params.runParam }), - superScopes: ["write:runs", "admin"], + resource: (params) => ({ type: "runs", id: params.runParam }), }, findResource: async (params, auth) => { return $replica.taskRun.findFirst({ diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index e45f7508b90..8b2be6e3ca5 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -9,7 +9,10 @@ import { env } from "~/env.server"; import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.server"; import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; import { handleRequestIdempotency, saveRequestIdempotency, @@ -32,10 +35,17 @@ const { action, loader } = createActionApiRoute( maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE, authorization: { action: "batchTrigger", - resource: (_, __, ___, body) => ({ - tasks: Array.from(new Set(body.items.map((i) => i.task))), - }), - superScopes: ["write:tasks", "admin"], + // Each item in the batch is a distinct task β€” every one must be + // authorized, not just any one of them. `everyResource` flips + // the auth check to AND semantics so a JWT scoped to taskA can't + // submit a batch that also includes taskB / taskC. + resource: (_, __, ___, body) => + everyResource( + Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({ + type: "tasks", + id, + })) + ), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v3.batches.ts b/apps/webapp/app/routes/api.v3.batches.ts index b671a8efbd6..f4227106765 100644 --- a/apps/webapp/app/routes/api.v3.batches.ts +++ b/apps/webapp/app/routes/api.v3.batches.ts @@ -35,12 +35,9 @@ const { action, loader } = createActionApiRoute( maxContentLength: 131_072, // 128KB is plenty for the batch metadata authorization: { action: "batchTrigger", - resource: () => ({ - // No specific tasks to authorize at batch creation time - // Tasks are validated when items are streamed - tasks: [], - }), - superScopes: ["write:tasks", "admin"], + // No specific tasks to authorize at batch creation time β€” tasks are + // validated when items are streamed. Collection-level check. + resource: () => ({ type: "tasks" }), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts index de40a9a9120..00ea7102580 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -1,7 +1,10 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ runId: z.string(), @@ -18,13 +21,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ authentication, resource, apiVersion }) => { diff --git a/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts b/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts index 0c88cc45f61..a3f35013b78 100644 --- a/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts +++ b/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts @@ -5,7 +5,7 @@ import { WorkerApiRunAttemptStartRequestBody, WorkerApiRunAttemptStartResponseBody, } from "@trigger.dev/core/v3/workers"; -import { RuntimeEnvironment } from "@trigger.dev/database"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; import { defaultMachine } from "~/services/platform.v3.server"; import { z } from "zod"; import { prisma } from "~/db.server"; @@ -76,7 +76,7 @@ const { action } = createActionApiRoute( ); async function getEnvVars( - environment: RuntimeEnvironment, + environment: AuthenticatedEnvironment, runId: string, machinePreset: MachinePreset, taskEventStore?: string diff --git a/apps/webapp/app/routes/invite-resend.tsx b/apps/webapp/app/routes/invite-resend.tsx index dc66e898517..5dc285b944f 100644 --- a/apps/webapp/app/routes/invite-resend.tsx +++ b/apps/webapp/app/routes/invite-resend.tsx @@ -4,7 +4,7 @@ import { env } from "process"; import { z } from "zod"; import { resendInvite } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; -import { scheduleEmail } from "~/services/email.server"; +import { scheduleEmail } from "~/services/scheduleEmail.server"; import { requireUserId } from "~/services/session.server"; import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder"; diff --git a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts index 33449deebca..96376b8850c 100644 --- a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts +++ b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts @@ -23,8 +23,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (batch) => ({ batch: batch.friendlyId }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (batch) => ({ type: "batch", id: batch.friendlyId }), }, }, async ({ authentication, request, resource: batchRun, apiVersion }) => { diff --git a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts index 060f937b0eb..e03787c6200 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { realtimeClient } from "~/services/realtimeClientGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ runId: z.string(), @@ -31,13 +34,17 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ authentication, request, resource: run, apiVersion }) => { diff --git a/apps/webapp/app/routes/realtime.v1.runs.ts b/apps/webapp/app/routes/realtime.v1.runs.ts index 18eeeb0a075..c829ca89d6d 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.ts @@ -1,7 +1,10 @@ import { z } from "zod"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { realtimeClient } from "~/services/realtimeClientGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const SearchParamsSchema = z.object({ tags: z @@ -21,8 +24,11 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, // This is a dummy value, it's not used authorization: { action: "read", - resource: (_, __, searchParams) => searchParams, - superScopes: ["read:runs", "read:all", "admin"], + resource: (_, __, searchParams) => + anyResource([ + { type: "runs" }, + ...(searchParams.tags ?? []).map((tag) => ({ type: "tags", id: tag })), + ]), }, }, async ({ searchParams, authentication, request, apiVersion }) => { diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts index 45fbde5924b..60c8e209262 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts @@ -12,7 +12,10 @@ import { } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createActionApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { engine } from "~/v3/runEngine.server"; import { ServiceValidationError } from "~/v3/services/common.server"; @@ -49,15 +52,16 @@ const { action, loader } = createActionApiRoute( action: "write", // Authorize against the union of the URL form, friendlyId, and // externalId so a JWT scoped to any form authorizes any URL. + // Type-level `write:sessions` (no id) also matches; `write:all` / + // `admin` bypass via the JWT ability's wildcard branches. resource: (params, _, __, ___, session) => { const ids = new Set([params.session]); if (session) { ids.add(session.friendlyId); if (session.externalId) ids.add(session.externalId); } - return { sessions: [...ids] }; + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, - superScopes: ["write:sessions", "write:all", "admin"], }, }, async ({ request, params, authentication, resource: session }) => { diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts index 37ec58c51ae..562b19fad99 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts @@ -10,6 +10,7 @@ import { } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -30,8 +31,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "write", - resource: (params) => ({ sessions: params.session }), - superScopes: ["write:sessions", "write:all", "admin"], + resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ params, authentication }) => { @@ -116,15 +116,18 @@ const loader = createLoaderApiRoute( }, authorization: { action: "read", + // Multi-key: the channel is addressable by the URL key, the row's + // friendlyId, and (if set) externalId. Type-level `read:sessions` + // matches any of them; `read:all` / `admin` bypass via the JWT + // ability's wildcard branches. resource: ({ row, addressingKey }) => { const ids = new Set([addressingKey]); if (row) { ids.add(row.friendlyId); if (row.externalId) ids.add(row.externalId); } - return { sessions: [...ids] }; + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, - superScopes: ["read:sessions", "read:all", "admin"], }, }, async ({ params, request, authentication, resource }) => { diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts index 477ce781a20..39935b9de1f 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; const ParamsSchema = z.object({ @@ -89,7 +92,13 @@ export const loader = createLoaderApiRoute( friendlyId: params.runId, runtimeEnvironmentId: auth.environment.id, }, - include: { + select: { + id: true, + friendlyId: true, + taskIdentifier: true, + runTags: true, + realtimeStreamsVersion: true, + streamBasinName: true, batch: { select: { friendlyId: true, @@ -100,13 +109,17 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ params, request, resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts index 089f2dc55e3..335116043d1 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts @@ -7,6 +7,7 @@ import { deleteInputStreamWaitpoint, } from "~/services/inputStreamWaitpointCache.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -31,8 +32,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "write", - resource: (params) => ({ inputStreams: params.runId }), - superScopes: ["write:inputStreams", "write:all", "admin"], + resource: (params) => ({ type: "inputStreams", id: params.runId }), }, }, async ({ request, params, authentication }) => { @@ -127,13 +127,17 @@ const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ params, request, resource: run, authentication }) => { diff --git a/apps/webapp/app/runEngine/concerns/batchLimits.server.ts b/apps/webapp/app/runEngine/concerns/batchLimits.server.ts index f40088039eb..8cd7bf72c6a 100644 --- a/apps/webapp/app/runEngine/concerns/batchLimits.server.ts +++ b/apps/webapp/app/runEngine/concerns/batchLimits.server.ts @@ -32,7 +32,16 @@ function createBatchLimitsRedisClient() { return redisClient; } -function createOrganizationRateLimiter(organization: Organization): RateLimiter { +// Just the org fields this module reads. Compatible with both the full +// Prisma `Organization` payload and the slim `AuthenticatedEnvironment` +// `["organization"]` shape (when passed `batchRateLimitConfig` / +// `batchQueueConcurrencyConfig` as `unknown`). +type OrganizationForBatchLimits = { + batchRateLimitConfig?: unknown; + batchQueueConcurrencyConfig?: unknown; +}; + +function createOrganizationRateLimiter(organization: OrganizationForBatchLimits): RateLimiter { const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig); const limiter = createLimiterFromConfig(limiterConfig); @@ -72,7 +81,7 @@ function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimite * Internally looks up the plan type, but doesn't expose it to callers. */ export async function getBatchLimits( - organization: Organization + organization: OrganizationForBatchLimits ): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> { const rateLimiter = createOrganizationRateLimiter(organization); const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig); diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 611953efc4f..537841de447 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -1,5 +1,4 @@ import { json } from "@remix-run/server-runtime"; -import { type Prettify } from "@trigger.dev/core"; import { SignJWT, errors, jwtVerify } from "jose"; import { z } from "zod"; @@ -23,7 +22,7 @@ import { isOrganizationAccessToken, } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; -import { sanitizeBranchName } from "~/v3/gitBranch"; +import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -36,12 +35,10 @@ const ClaimsSchema = z.object({ .optional(), }); -type Optional = Prettify & Partial>>; - -export type AuthenticatedEnvironment = Optional< - NonNullable>>, - "orgMember" ->; +// Re-export the slim shape defined in @trigger.dev/core. Single source of +// truth across the auth boundary (RBAC plugin contract β†’ webapp handlers). +export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; export type ApiAuthenticationResult = | ApiAuthenticationResultSuccess diff --git a/apps/webapp/app/services/email.server.ts b/apps/webapp/app/services/email.server.ts index 290addcdc97..17fba0c52e1 100644 --- a/apps/webapp/app/services/email.server.ts +++ b/apps/webapp/app/services/email.server.ts @@ -4,7 +4,6 @@ import type { SendEmailOptions } from "remix-auth-email-link"; import { redirect } from "remix-typedjson"; import { env } from "~/env.server"; import type { AuthUser } from "./authUser"; -import { commonWorker } from "~/v3/commonWorker.server"; import { logger } from "./logger.server"; import { singleton } from "~/utils/singleton"; import { assertEmailAllowed } from "~/utils/email"; @@ -92,15 +91,6 @@ export async function sendPlainTextEmail(options: SendPlainTextOptions) { return client.sendPlainText(options); } -export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) { - const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined; - await commonWorker.enqueue({ - job: "scheduleEmail", - payload: data, - availableAt, - }); -} - export async function sendEmail(data: DeliverEmail) { return client.send(data); } diff --git a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts index 3aa28bc01db..3774a84ef32 100644 --- a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts +++ b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts @@ -7,7 +7,7 @@ import { createHash } from "@better-auth/utils/hash"; import { createOTP } from "@better-auth/utils/otp"; import { base32 } from "@better-auth/utils/base32"; import { z } from "zod"; -import { scheduleEmail } from "../email.server"; +import { scheduleEmail } from "../scheduleEmail.server"; const generateRandomString = createRandomStringGenerator("A-Z", "0-9"); diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index e781cdfeb7a..f985ab6f61e 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; +import { rbac } from "./rbac.server"; import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server"; import { env } from "~/env.server"; @@ -16,9 +17,26 @@ const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", toke // staleness is fine. export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000; +// The OSS fallback's setTokenRole returns this exact string when no +// enterprise plugin is loaded. We treat that as "no role attached" β€” +// the PAT is still valid; auth just falls through to legacy permissive +// behaviour. Any other error is treated as a real failure and triggers +// the compensating delete below. +// Must match the OSS fallback's exact error string (see +// `internal-packages/rbac/src/fallback.ts`'s `setTokenRole`). The +// match is how we detect "no plugin installed" and skip the +// compensating delete. +const FALLBACK_NOT_INSTALLED_ERROR = "RBAC plugin not installed"; + type CreatePersonalAccessTokenOptions = { name: string; userId: string; + // Optional: when provided, persist a TokenRole row alongside the PAT + // so PAT-authenticated requests pick up that role's permissions + // (TRI-8749). The dashboard tokens page passes a chosen system role; + // the CLI auth-code path doesn't pass one (legacy behaviour + // preserved β€” those PATs run with no explicit role). + roleId?: string; }; /** Returns obfuscated access tokens that aren't revoked */ @@ -338,6 +356,7 @@ export async function createPersonalAccessTokenFromAuthorizationCode( export async function createPersonalAccessToken({ name, userId, + roleId, }: CreatePersonalAccessTokenOptions) { const token = createToken(); const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY); @@ -352,6 +371,45 @@ export async function createPersonalAccessToken({ }, }); + // Persist the role choice via the RBAC plugin's setTokenRole. The + // plugin may store this in a separate datastore from Prisma (e.g. + // Drizzle on a different schema), so co-transactional inserts are + // awkward β€” we use a compensating-delete pattern instead: if + // setTokenRole fails, roll back the PAT row by deleting it. The auth + // path treats "no role" as permissive (matches the default fallback) + // so a brief orphan window between the two writes is harmless. The + // compensating delete narrows that window from "until manual cleanup" + // to "until the request returns". + if (roleId) { + const roleResult = await rbac.setTokenRole({ + tokenId: personalAccessToken.id, + roleId, + }); + if (!roleResult.ok) { + // The default fallback always returns ok=false with this exact + // message. That isn't a failure β€” there's no plugin to write to, + // so the PAT just runs without an explicit role (matches the + // pre-RBAC behaviour). Don't compensating-delete in that case. + if (roleResult.error === FALLBACK_NOT_INSTALLED_ERROR) { + logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", { + patId: personalAccessToken.id, + userId, + }); + } else { + await prisma.personalAccessToken + .delete({ where: { id: personalAccessToken.id } }) + .catch((err) => { + logger.error("Failed to compensating-delete PAT after TokenRole insert failed", { + patId: personalAccessToken.id, + roleResultError: roleResult.error, + deleteError: err instanceof Error ? err.message : String(err), + }); + }); + throw new Error(`Failed to assign role to access token: ${roleResult.error}`); + } + } + } + return { id: personalAccessToken.id, name, diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 6df93c9c0e9..bd189b4fb5a 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1,5 +1,5 @@ import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3"; -import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { BillingClient, defaultMachine as defaultMachineFromPlatform, @@ -25,7 +25,6 @@ import { redirect } from "remix-typedjson"; import { z } from "zod"; import { env } from "~/env.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { createEnvironment } from "~/models/organization.server"; import { logger } from "~/services/logger.server"; import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder"; import { singleton } from "~/utils/singleton"; @@ -598,33 +597,6 @@ export async function getEntitlement( return result.val; } -export async function projectCreated( - organization: Pick, - project: Project -) { - if (!isCloud()) { - await createEnvironment({ organization, project, type: "STAGING" }); - await createEnvironment({ - organization, - project, - type: "PREVIEW", - isBranchableEnvironment: true, - }); - } else { - //staging is only available on certain plans - const plan = await getCurrentPlan(organization.id); - if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) { - await createEnvironment({ organization, project, type: "STAGING" }); - await createEnvironment({ - organization, - project, - type: "PREVIEW", - isBranchableEnvironment: true, - }); - } - } -} - export async function getBillingAlerts( organizationId: string ): Promise { @@ -789,7 +761,7 @@ export async function triggerInitialDeployment( } } -function isCloud(): boolean { +export function isCloud(): boolean { const acceptableHosts = [ "https://cloud.trigger.dev", "https://test-cloud.trigger.dev", diff --git a/apps/webapp/app/services/projectCreated.server.ts b/apps/webapp/app/services/projectCreated.server.ts new file mode 100644 index 00000000000..f845af52033 --- /dev/null +++ b/apps/webapp/app/services/projectCreated.server.ts @@ -0,0 +1,35 @@ +import type { Organization, Project } from "@trigger.dev/database"; +import { createEnvironment } from "~/models/organization.server"; +import { getCurrentPlan, isCloud } from "~/services/platform.v3.server"; + +// Extracted from platform.v3.server.ts to break a circular import: +// platform.v3.server ↔ models/organization.server (via createEnvironment). +// The cycle caused the bundled __esm wrappers to re-enter and short-circuit +// the platform.v3.server init, leaving `defaultMachine` and `machines` +// undefined in `singleton("machinePresets", ...)` β€” the boot crash at +// `allMachines()` traced to TRI-8731. +export async function projectCreated( + organization: Pick, + project: Project +) { + if (!isCloud()) { + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); + } else { + const plan = await getCurrentPlan(organization.id); + if (plan?.v3Subscription?.plan?.limits?.hasStagingEnvironment) { + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); + } + } +} diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts new file mode 100644 index 00000000000..49b6f88ecf4 --- /dev/null +++ b/apps/webapp/app/services/rbac.server.ts @@ -0,0 +1,29 @@ +import { $replica, prisma } from "~/db.server"; +import type { PrismaClient } from "@trigger.dev/database"; +import plugin from "@trigger.dev/rbac"; +import { env } from "~/env.server"; + +// plugin.create() is synchronous β€” returns a lazy controller that resolves +// any installed RBAC plugin on first call. Top-level await is not used +// because CJS output format does not support it. +// +// Auth-path reads run on every request β€” pass the replica explicitly so +// they don't pile up on the primary. Writes (role mutations) still go +// through the primary. Same separation findEnvironmentByApiKey used +// before this PR moved bearer auth into the RBAC plugin. +// +// Session-cookie userId resolution lives at the call site (see +// dashboardBuilder.server.ts), not here. Statically importing +// `~/services/session.server` from this module dragged the entire +// remix-auth pipeline (auth.server β†’ emailAuth/gitHubAuth/googleAuth, +// each validating their secret at module load) into anything that +// transitively imported `rbac` β€” including PAT auth callers that have +// no session-cookie path at all. Passing userId through the +// `authenticateSession` context decouples the plugin host from the +// host's session implementation. +export const rbac = plugin.create( + // $replica is structurally a PrismaClient minus `$transaction` β€” the + // RBAC fallback only uses `findFirst` on it, so the cast is safe. + { primary: prisma, replica: $replica as PrismaClient }, + { forceFallback: env.RBAC_FORCE_FALLBACK } +); diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts index 00266075d0f..66b7ccf2258 100644 --- a/apps/webapp/app/services/realtime/jwtAuth.server.ts +++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts @@ -126,9 +126,7 @@ export function isPublicJWT(token: string): boolean { } } -export function extractJwtSigningSecretKey( - environment: AuthenticatedEnvironment & { parentEnvironment?: { apiKey: string } } -) { +export function extractJwtSigningSecretKey(environment: AuthenticatedEnvironment) { return environment.parentEnvironment?.apiKey ?? environment.apiKey; } diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 9e439938d0d..c803c726b93 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -1,17 +1,11 @@ import { z } from "zod"; -import { - ApiAuthenticationResultSuccess, - authenticateApiRequestWithFailure, -} from "../apiAuth.server"; +import { ApiAuthenticationResultSuccess } from "../apiAuth.server"; import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { fromZodError } from "zod-validation-error"; import { apiCors } from "~/utils/apiCors"; -import { - AuthorizationAction, - AuthorizationResources, - checkAuthorization, -} from "../authorization.server"; import { logger } from "../logger.server"; +import { rbac } from "../rbac.server"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import { authenticateApiRequestWithPersonalAccessToken, PersonalAccessTokenAuthenticationResult, @@ -50,8 +44,135 @@ function logBoundaryError( } } +// Bridges the RBAC plugin (source of truth for auth + abilities) to the legacy +// ApiAuthenticationResultSuccess shape route handlers still expect. All three +// apiBuilder call sites funnel through this helper β€” no handler-level changes +// needed. +async function authenticateRequestForApiBuilder( + request: Request, + { allowJWT }: { allowJWT: boolean } +): Promise< + | { ok: false; status: 401 | 403; error: string } + | { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility } +> { + const result = await rbac.authenticateBearer(request, { allowJWT }); + if (!result.ok) { + // Plugin auth distinguishes 401 (who are you?) from 403 (you're not + // allowed) β€” e.g. a suspended account or IP block returns 403. + // Forwarding the status preserves that semantic for client retry logic. + return { ok: false, status: result.status, error: result.error }; + } + + // Plugins return the full AuthenticatedEnvironment shape directly β€” no + // follow-up DB lookup. The fallback fetches via Prisma, the cloud plugin + // via Drizzle; both produce the same slim contract type. + const authentication: ApiAuthenticationResultSuccess = { + ok: true, + apiKey: result.environment.apiKey, + type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE", + environment: result.environment, + realtime: result.jwt?.realtime, + oneTimeUse: result.jwt?.oneTimeUse, + }; + + return { ok: true, authentication, ability: result.ability }; +} + type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; +// Sentinel ability for routes that don't opt into the cap-and-floor PAT +// model β€” preserves pre-RBAC behaviour where PATs were pure user-identity +// tokens. New routes that want gated PAT auth declare a `context` and +// `authorization` block; the actual ability comes from `rbac.authenticatePat`. +const PERMISSIVE_ABILITY: RbacAbility = { + can: () => true, + canSuper: () => false, +}; + +// A multi-resource auth check has two possible directions, and route authors +// have to pick one explicitly: +// +// - `anyResource(...)` β€” succeed if *any* element passes. Used when a single +// record carries multiple identifiers (a run is addressable by friendlyId / +// batch / tags / task) so a JWT scoped to *any* of them grants access. +// +// - `everyResource(...)` β€” succeed only if *every* element passes. Used for +// batch operations where each element is a *distinct* resource and a JWT +// scoped to one element must not authorize the others. +// +// Bare `RbacResource[]` is intentionally *not* part of `AuthResource` β€” the +// type system forces every multi-resource site to disambiguate. The original +// pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for +// "broader-than-this-resource" access; post-RBAC that's expressed via the JWT +// ability's wildcard branches (`*:all` and `admin*` β€” see +// `internal-packages/rbac/src/ability.ts`) plus a collection-level shape +// `{ type: "" }` (no id) in the `anyResource` array so a +// `:` JWT matches it. No code knob needed. +// +// Markers are Symbols so they can't collide with arbitrary RbacResource fields. +const ANY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.anyResource"); +const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource"); + +type AnyResourceAuth = { + readonly [ANY_RESOURCE_MARKER]: true; + readonly resources: readonly RbacResource[]; +}; + +type EveryResourceAuth = { + readonly [EVERY_RESOURCE_MARKER]: true; + readonly resources: readonly RbacResource[]; +}; + +export function anyResource(resources: RbacResource[]): AnyResourceAuth { + return { [ANY_RESOURCE_MARKER]: true, resources }; +} + +export function everyResource(resources: RbacResource[]): EveryResourceAuth { + return { [EVERY_RESOURCE_MARKER]: true, resources }; +} + +function isAnyResource(value: unknown): value is AnyResourceAuth { + return ( + typeof value === "object" && + value !== null && + (value as Record)[ANY_RESOURCE_MARKER] === true + ); +} + +function isEveryResource(value: unknown): value is EveryResourceAuth { + return ( + typeof value === "object" && + value !== null && + (value as Record)[EVERY_RESOURCE_MARKER] === true + ); +} + +type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth; + +function checkAuth( + ability: RbacAbility, + action: string, + resource: AuthResource +): boolean { + if (isEveryResource(resource)) { + // Empty array via [].every() is vacuously true β€” would let any token + // pass auth. Routes building everyResource() from request bodies + // (e.g. batch trigger items) should never produce zero elements + // because body validation rejects empty arrays first, but defending + // here anyway since the auth layer should never grant on no input. + if (resource.resources.length === 0) return false; + return resource.resources.every((r) => ability.can(action, r)); + } + if (isAnyResource(resource)) { + // Symmetric guard: anyResource([]) is benign for most abilities + // (.some() is false on empty), but PERMISSIVE_ABILITY would still + // grant. Treat empty as "no resource declared" β†’ deny. + if (resource.resources.length === 0) return false; + return ability.can(action, [...resource.resources]); + } + return ability.can(action, resource); +} + type ApiKeyRouteBuilderOptions< TParamsSchema extends AnyZodSchema | undefined = undefined, TSearchParamsSchema extends AnyZodSchema | undefined = undefined, @@ -76,7 +197,7 @@ type ApiKeyRouteBuilderOptions< ) => Promise; shouldRetryNotFound?: boolean; authorization?: { - action: AuthorizationAction; + action: string; resource: ( resource: NonNullable, params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion @@ -90,8 +211,7 @@ type ApiKeyRouteBuilderOptions< headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer : undefined - ) => AuthorizationResources; - superScopes?: string[]; + ) => AuthResource; }; }; @@ -144,23 +264,15 @@ export function createLoaderApiRoute< } try { - const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT }); - - if (!authenticationResult) { - return await wrapResponse( - request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - - if (!authenticationResult.ok) { + const authResult = await authenticateRequestForApiBuilder(request, { allowJWT }); + if (!authResult.ok) { return await wrapResponse( request, - json({ error: authenticationResult.error }, { status: 401 }), + json({ error: authResult.error }, { status: authResult.status }), corsStrategy !== "none" ); } + const { authentication: authenticationResult, ability } = authResult; let parsedParams: any = undefined; if (paramsSchema) { @@ -227,7 +339,7 @@ export function createLoaderApiRoute< } if (authorization) { - const { action, resource: authResource, superScopes } = authorization; + const { action, resource: authResource } = authorization; const $authResource = authResource( resource, parsedParams, @@ -235,26 +347,12 @@ export function createLoaderApiRoute< parsedHeaders ); - logger.debug("Checking authorization", { - action, - resource: $authResource, - superScopes, - scopes: authenticationResult.scopes, - }); - - const authorizationResult = checkAuthorization( - authenticationResult, - action, - $authResource, - superScopes - ); - - if (!authorizationResult.authorized) { + if (!checkAuth(ability, action, $authResource)) { return await wrapResponse( request, json( { - error: `Unauthorized: ${authorizationResult.reason}`, + error: "Unauthorized", code: "unauthorized", param: "access_token", type: "authorization", @@ -309,6 +407,37 @@ type PATRouteBuilderOptions< searchParams?: TSearchParamsSchema; headers?: THeadersSchema; corsStrategy?: "all" | "none"; + // Resolves the target org/project for the request. Fed to + // `rbac.authenticatePat` so the plugin can compute the user's role + // floor (their authority in that org) for the cap intersection. + // When omitted, the PAT runs in identity-only mode β€” no role floor, + // no per-route ability gating beyond what authorization (if any) + // declares against a permissive baseline. Routes added before TRI-9087 + // run in this mode by default. + context?: ( + params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + request: Request + ) => + | { organizationId?: string; projectId?: string } + | Promise<{ organizationId?: string; projectId?: string }>; + authorization?: { + action: string; + resource: ( + params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined + ) => AuthResource; + }; }; type PATHandlerFunction< @@ -328,6 +457,7 @@ type PATHandlerFunction< ? z.infer : undefined; authentication: PersonalAccessTokenAuthenticationResult; + ability: RbacAbility; request: Request; apiVersion: API_VERSIONS; }) => Promise; @@ -346,6 +476,8 @@ export function createLoaderPATApiRoute< searchParams: searchParamsSchema, headers: headersSchema, corsStrategy = "none", + context: contextFn, + authorization, } = options; if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { @@ -415,11 +547,53 @@ export function createLoaderPATApiRoute< const apiVersion = getApiVersion(request); + // Resolve ability via the rbac plugin. When neither `context` nor + // `authorization` is declared, the legacy permissive ability stands + // in β€” preserves the pre-RBAC PAT behaviour for routes that + // haven't opted into the cap-and-floor model yet. + let ability: RbacAbility = PERMISSIVE_ABILITY; + if (contextFn || authorization) { + const ctx = contextFn ? await contextFn(parsedParams, request) : {}; + const patAuth = await rbac.authenticatePat(request, ctx); + if (!patAuth.ok) { + return await wrapResponse( + request, + json({ error: patAuth.error }, { status: patAuth.status }), + corsStrategy !== "none" + ); + } + ability = patAuth.ability; + + if (authorization) { + const $resource = authorization.resource( + parsedParams, + parsedSearchParams, + parsedHeaders + ); + if (!checkAuth(ability, authorization.action, $resource)) { + return await wrapResponse( + request, + json( + { + error: "Unauthorized", + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 } + ), + corsStrategy !== "none" + ); + } + } + } + const result = await handler({ params: parsedParams, searchParams: parsedSearchParams, headers: parsedHeaders, authentication: authenticationResult, + ability, request, apiVersion, }); @@ -468,7 +642,7 @@ type ApiKeyActionRouteBuilderOptions< : undefined ) => Promise; authorization?: { - action: AuthorizationAction; + action: string; resource: ( params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer @@ -490,8 +664,7 @@ type ApiKeyActionRouteBuilderOptions< // externalId for sessions) read it here so a JWT minted for either form // authorizes both URL forms. resource: TResource | undefined - ) => AuthorizationResources; - superScopes?: string[]; + ) => AuthResource; }; maxContentLength?: number; body?: TBodySchema; @@ -579,23 +752,15 @@ export function createActionApiRoute< } try { - const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT }); - - if (!authenticationResult) { + const authResult = await authenticateRequestForApiBuilder(request, { allowJWT }); + if (!authResult.ok) { return await wrapResponse( request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - - if (!authenticationResult.ok) { - return await wrapResponse( - request, - json({ error: authenticationResult.error }, { status: 401 }), + json({ error: authResult.error }, { status: authResult.status }), corsStrategy !== "none" ); } + const { authentication: authenticationResult, ability } = authResult; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -706,7 +871,7 @@ export function createActionApiRoute< // - PRIVATE key + missing resource β†’ auth passes β†’ 404 (correct) // - PRIVATE key + existing resource β†’ auth passes β†’ handler runs if (authorization) { - const { action, resource: authResource, superScopes } = authorization; + const { action, resource: authResource } = authorization; const $resource = authResource( parsedParams, parsedSearchParams, @@ -715,26 +880,12 @@ export function createActionApiRoute< resource ); - logger.debug("Checking authorization", { - action, - resource: $resource, - superScopes, - scopes: authenticationResult.scopes, - }); - - const authorizationResult = checkAuthorization( - authenticationResult, - action, - $resource, - superScopes - ); - - if (!authorizationResult.authorized) { + if (!checkAuth(ability, action, $resource)) { return await wrapResponse( request, json( { - error: `Unauthorized: ${authorizationResult.reason}`, + error: "Unauthorized", code: "unauthorized", param: "access_token", type: "authorization", @@ -825,9 +976,8 @@ type MultiMethodApiRouteOptions< allowJWT?: boolean; corsStrategy?: "all" | "none"; authorization?: { - action: AuthorizationAction; - resource: (params: InferZod) => AuthorizationResources; - superScopes?: string[]; + action: string; + resource: (params: InferZod) => AuthResource; }; maxContentLength?: number; methods: Partial< @@ -872,33 +1022,22 @@ export function createMultiMethodApiRoute< if (!methodConfig) { return await wrapResponse( request, - json( - { error: "Method not allowed" }, - { status: 405, headers: { Allow: allowedMethods } } - ), + json({ error: "Method not allowed" }, { status: 405, headers: { Allow: allowedMethods } }), corsStrategy !== "none" ); } try { // Authenticate - const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT }); - - if (!authenticationResult) { + const authResult = await authenticateRequestForApiBuilder(request, { allowJWT }); + if (!authResult.ok) { return await wrapResponse( request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - - if (!authenticationResult.ok) { - return await wrapResponse( - request, - json({ error: authenticationResult.error }, { status: 401 }), + json({ error: authResult.error }, { status: authResult.status }), corsStrategy !== "none" ); } + const { authentication: authenticationResult, ability } = authResult; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -966,29 +1105,15 @@ export function createMultiMethodApiRoute< // Authorize if (authorization) { - const { action, resource, superScopes } = authorization; + const { action, resource } = authorization; const $resource = resource(parsedParams); - logger.debug("Checking authorization", { - action, - resource: $resource, - superScopes, - scopes: authenticationResult.scopes, - }); - - const authorizationResult = checkAuthorization( - authenticationResult, - action, - $resource, - superScopes - ); - - if (!authorizationResult.authorized) { + if (!checkAuth(ability, action, $resource)) { return await wrapResponse( request, json( { - error: `Unauthorized: ${authorizationResult.reason}`, + error: "Unauthorized", code: "unauthorized", param: "access_token", type: "authorization", diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts new file mode 100644 index 00000000000..656761d852d --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts @@ -0,0 +1,117 @@ +// Server-only impl backing dashboardBuilder.ts. Imports rbac.server and +// runs the actual auth/authorization. The wrappers in dashboardBuilder.ts +// dynamic-import this module from inside the loader/action body, so it +// never reaches the client bundle. + +import { json, redirect } from "@remix-run/server-runtime"; +import type { RbacAbility } from "@trigger.dev/rbac"; +import { rbac } from "~/services/rbac.server"; +import { getUserId } from "~/services/session.server"; +import type { + AuthorizationOption, + DashboardLoaderOptions, + SessionUser, +} from "./dashboardBuilder"; +import { fromZodError } from "zod-validation-error"; +import type { z } from "zod"; + +type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; + +function loginRedirectFor(request: Request, override?: string): Response { + if (override) return redirect(override); + const url = new URL(request.url); + const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`); + return redirect(`/login?redirectTo=${redirectTo}`); +} + +function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean { + if ("requireSuper" in authorization) { + return ability.canSuper(); + } + return ability.can(authorization.action, authorization.resource); +} + +type AuthScope = { organizationId?: string; projectId?: string }; + +export async function authenticateAndAuthorize< + TParams, + TSearchParams, + TContext extends AuthScope +>( + request: Request, + rawParams: unknown, + options: DashboardLoaderOptions +): Promise< + | { ok: false; response: Response } + | { + ok: true; + user: SessionUser; + ability: RbacAbility; + params: unknown; + searchParams: unknown; + context: TContext; + } +> { + let parsedParams: any = undefined; + if (options.params) { + const parsed = (options.params as unknown as AnyZodSchema).safeParse(rawParams); + if (!parsed.success) { + return { + ok: false, + response: json( + { error: "Params Error", details: fromZodError(parsed.error).details }, + { status: 400 } + ), + }; + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (options.searchParams) { + const fromUrl = Object.fromEntries(new URL(request.url).searchParams); + const parsed = (options.searchParams as unknown as AnyZodSchema).safeParse(fromUrl); + if (!parsed.success) { + return { + ok: false, + response: json( + { error: "Query Error", details: fromZodError(parsed.error).details }, + { status: 400 } + ), + }; + } + parsedSearchParams = parsed.data; + } + + const ctx = (options.context + ? await options.context(parsedParams, request) + : ({} as TContext)) as TContext; + // Resolve userId from the session cookie *here* (the dashboard + // request boundary) and feed it into the rbac plugin context. The + // plugin no longer takes a `helpers.getSessionUserId` callback β€” + // statically importing session.server from rbac.server dragged the + // entire remix-auth strategy chain (each strategy validates its + // secret at module load) into anything that pulled `rbac` in, + // including PAT-only callers. + const userId = (await getUserId(request)) ?? null; + const auth = await rbac.authenticateSession(request, { ...ctx, userId }); + if (!auth.ok) { + if (auth.reason === "unauthenticated") { + return { ok: false, response: loginRedirectFor(request, options.loginRedirect) }; + } + return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; + } + + if (options.authorization && !isAuthorized(auth.ability, options.authorization)) { + return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; + } + + return { + ok: true, + user: auth.user, + ability: auth.ability, + params: parsedParams, + searchParams: parsedSearchParams, + context: ctx, + }; +} diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts new file mode 100644 index 00000000000..673f4f78deb --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts @@ -0,0 +1,141 @@ +// Client-safe shim for the dashboard route builder. The actual server +// implementation lives in dashboardBuilder.server.ts; the wrappers here +// just return closures that lazily import that impl on first invocation. +// +// Why split: routes use `export const loader = dashboardLoader(...)` at +// module top-level. Remix's dev build preserves the top-level call when +// resolving the loader export, so the import target needs to exist on +// the client even though the closure body never executes there. A +// `.server.ts` file is excluded from the client bundle, which would +// resolve `dashboardLoader` to undefined and crash with +// "dashboardLoader is not a function" on first navigation. Keeping this +// file non-`.server` puts the wrappers in the client bundle as +// effectively no-op closures (they're never called there), and the +// closure body's dynamic import only resolves at server runtime. + +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; +import type { z } from "zod"; + +type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; + +type InferZod = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + +export type SessionUser = { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + confirmedBasicDetails: boolean; + isImpersonating: boolean; +}; + +// `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit +// action + resource pair is checked via ability.can(...). +export type AuthorizationOption = + | { requireSuper: true } + | { + action: string; + resource: RbacResource | RbacResource[]; + }; + +// Plugin-side scope: whatever the route's `context` returns must include +// these (or just be `{}` when the route doesn't scope by org/project). +// rbac.authenticateSession reads them off the value to filter UserRole. +type AuthScope = { organizationId?: string; projectId?: string }; + +export type DashboardLoaderOptions = { + params?: TParams; + searchParams?: TSearchParams; + // Resolves any per-request data the handler + auth check both need + // (typically org/project lookups from URL params). The returned object + // is fed to `rbac.authenticateSession` as the auth scope AND passed + // through to the handler in `args.context`, so the route does each + // lookup once. + context?: ( + params: InferZod, + request: Request + ) => TContext | Promise; + authorization?: AuthorizationOption; + // Where to send unauthenticated requests. Defaults to /login with a + // redirectTo back to the original path. + loginRedirect?: string; + // Where to send users who pass auth but fail the ability check. Defaults + // to "/" (the home page). + unauthorizedRedirect?: string; +}; + +export type DashboardLoaderHandlerArgs = { + params: InferZod; + searchParams: InferZod; + user: SessionUser; + ability: RbacAbility; + context: TContext; + request: Request; +}; + +export function dashboardLoader< + TParams extends AnyZodSchema | undefined = undefined, + TSearchParams extends AnyZodSchema | undefined = undefined, + TContext extends AuthScope = AuthScope, + TReturn extends Response = Response +>( + options: DashboardLoaderOptions, + handler: ( + args: DashboardLoaderHandlerArgs + ) => Promise +) { + return async function loader({ request, params }: LoaderFunctionArgs): Promise { + // Server-only β€” see comment at top. Node caches the module after the + // first call, so the dynamic import is effectively free past warmup. + const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); + const result = await authenticateAndAuthorize(request, params, options); + if (!result.ok) throw result.response; + + return handler({ + params: result.params as InferZod, + searchParams: result.searchParams as InferZod, + user: result.user, + ability: result.ability, + context: result.context as TContext, + request, + }); + }; +} + +export type DashboardActionOptions = + DashboardLoaderOptions; + +export type DashboardActionHandlerArgs = + DashboardLoaderHandlerArgs; + +export function dashboardAction< + TParams extends AnyZodSchema | undefined = undefined, + TSearchParams extends AnyZodSchema | undefined = undefined, + TContext extends AuthScope = AuthScope, + TReturn extends Response = Response +>( + options: DashboardActionOptions, + handler: ( + args: DashboardActionHandlerArgs + ) => Promise +) { + return async function action({ request, params }: ActionFunctionArgs): Promise { + const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); + const result = await authenticateAndAuthorize(request, params, options); + if (!result.ok) throw result.response; + + return handler({ + params: result.params as InferZod, + searchParams: result.searchParams as InferZod, + user: result.user, + ability: result.ability, + context: result.context as TContext, + request, + }); + }; +} diff --git a/apps/webapp/app/services/scheduleEmail.server.ts b/apps/webapp/app/services/scheduleEmail.server.ts new file mode 100644 index 00000000000..0da19c86cb3 --- /dev/null +++ b/apps/webapp/app/services/scheduleEmail.server.ts @@ -0,0 +1,16 @@ +import type { DeliverEmail } from "emails"; +import { commonWorker } from "~/v3/commonWorker.server"; + +// Lives outside email.server.ts so that the SMTP/Resend client module +// stays a leaf dependency. Pulling commonWorker from email.server poisoned +// every consumer of the auth chain (auth β†’ emailAuth β†’ email) with the +// V1+V2 worker tree, which transitively loads marqs and trips Redis-env +// guards in any vitest file whose import graph reaches it. +export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) { + const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined; + await commonWorker.enqueue({ + job: "scheduleEmail", + payload: data, + availableAt, + }); +} diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 3b4e18a58ea..e13f5d244c8 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -3,7 +3,7 @@ import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; -import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; +import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { logger } from "./logger.server"; import { getCurrentPlan, getLimit } from "./platform.v3.server"; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 7a151053f5a..8f94b302ef7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -114,6 +114,10 @@ export function organizationTeamPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings/team`; } +export function organizationRolesPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/roles`; +} + export function inviteTeamMemberPath(organization: OrgForPath) { return `${organizationPath(organization)}/invite`; } diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index c6c72d79567..f2ca46d4d3a 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -1,4 +1,5 @@ import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; import { z } from "zod"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { $transaction, prisma } from "~/db.server"; @@ -876,8 +877,21 @@ export const RuntimeEnvironmentForEnvRepoPayload = { }, } as const; -export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload< - typeof RuntimeEnvironmentForEnvRepoPayload +// Derived from the slim AuthenticatedEnvironment so a full AE satisfies +// this type β€” the legacy Prisma payload had `builtInEnvironmentVariableOverrides` +// as Prisma's JsonValue, which is a subtype of `unknown` in the slim +// shape, causing assignability errors in the JWT/queue paths that pass +// AE values straight through. Using Pick aligns them. +export type RuntimeEnvironmentForEnvRepo = Pick< + AuthenticatedEnvironment, + | "id" + | "slug" + | "type" + | "projectId" + | "apiKey" + | "organizationId" + | "branchName" + | "builtInEnvironmentVariableOverrides" >; export const environmentVariablesRepository = new EnvironmentVariablesRepository(); @@ -1333,10 +1347,13 @@ function resolveBuiltInEnvironmentVariableOverrides( if ( !Array.isArray(overrides) && typeof overrides === "object" && - key in overrides && - typeof overrides[key] === "string" + overrides !== null && + key in overrides ) { - return overrides[key]; + const value = (overrides as Record)[key]; + if (typeof value === "string") { + return value; + } } return defaultValue; diff --git a/apps/webapp/app/v3/remoteImageBuilder.server.ts b/apps/webapp/app/v3/remoteImageBuilder.server.ts index a6f113022af..763ef2e95ff 100644 --- a/apps/webapp/app/v3/remoteImageBuilder.server.ts +++ b/apps/webapp/app/v3/remoteImageBuilder.server.ts @@ -1,13 +1,21 @@ import { depot } from "@depot/sdk-node"; import { type ExternalBuildData } from "@trigger.dev/core/v3"; -import { type Project } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import pRetry from "p-retry"; import { logger } from "~/services/logger.server"; +// Just the project columns this module reads β€” keeps the signature +// compatible with both the full Prisma `Project` payload and the slim +// `AuthenticatedEnvironment["project"]` shape. +type ProjectForBuilder = { + id: string; + externalRef: string; + builderProjectId: string | null; +}; + export async function createRemoteImageBuild( - project: Project + project: ProjectForBuilder ): Promise { if (!remoteBuildsEnabled()) { return; @@ -42,7 +50,7 @@ export async function createRemoteImageBuild( }; } -async function createBuilderProjectIfNotExists(project: Project) { +async function createBuilderProjectIfNotExists(project: ProjectForBuilder) { if (project.builderProjectId) { return project.builderProjectId; } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 0880eb71037..94cd8beef64 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -124,6 +124,7 @@ "@trigger.dev/companyicons": "^1.5.35", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", + "@trigger.dev/rbac": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", "@trigger.dev/platform": "1.0.27", "@trigger.dev/redis-worker": "workspace:*", diff --git a/apps/webapp/test/README.md b/apps/webapp/test/README.md new file mode 100644 index 00000000000..d1c2a418b39 --- /dev/null +++ b/apps/webapp/test/README.md @@ -0,0 +1,65 @@ +# Webapp tests + +Three suites live in this directory. + +## Unit tests β€” `*.test.ts` + +Run with `pnpm test` from `apps/webapp`. Default vitest pickup. No +container setup. Run on every PR via `unit-tests-webapp.yml`. + +## Smoke e2e β€” `*.e2e.test.ts` + +End-to-end auth baseline that proves the route auth plumbing is wired up. +Each file spins up its own webapp + Postgres + Redis container in +`beforeAll` (~30s startup). Vitest config: `vitest.e2e.config.ts`. Run on +every PR via `e2e-webapp.yml`. + +```bash +cd apps/webapp +pnpm exec vitest --config vitest.e2e.config.ts +``` + +## Comprehensive auth e2e β€” `*.e2e.full.test.ts` + +The full RBAC auth matrix β€” every route family with explicit pass/fail +scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for +each family's coverage spec. + +**Architecture**: one container reused across the whole suite via +`vitest.e2e.full.config.ts`'s `globalSetup`. Test files share the server +through `getTestServer()` from `helpers/sharedTestServer.ts`. Each test +seeds its own resources so order doesn't matter. + +**Layout**: + +| File | Top-level describe | Family subtasks | +|---|---|---| +| `auth-api.e2e.full.test.ts` | `API` | TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT | +| `auth-dashboard.e2e.full.test.ts` | `Dashboard` | TRI-8742 admin pages | +| `auth-cross-cutting.e2e.full.test.ts` | `Cross-cutting` | TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle | + +**Adding a new family**: pick the relevant file, add a nested `describe` +block. Inside, seed your own fixtures via the helpers and hit the shared +server. + +```ts +describe("Trigger task", () => { + const server = getTestServer(); + + it("missing Authorization β†’ 401", async () => { + const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" }); + expect(res.status).toBe(401); + }); +}); +``` + +**CI**: `e2e-webapp-auth-full.yml`. Triggers on `workflow_dispatch`, +nightly schedule, and PRs touching auth-relevant paths (route builders, +rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself). + +**Run locally**: + +```bash +cd apps/webapp +pnpm exec vitest --config vitest.e2e.full.config.ts +``` diff --git a/apps/webapp/test/api-auth.e2e.test.ts b/apps/webapp/test/api-auth.e2e.test.ts index c425ca7449c..31e365d6d40 100644 --- a/apps/webapp/test/api-auth.e2e.test.ts +++ b/apps/webapp/test/api-auth.e2e.test.ts @@ -11,6 +11,9 @@ import type { TestServer } from "@internal/testcontainers/webapp"; import { startTestServer } from "@internal/testcontainers/webapp"; import { generateJWT } from "@trigger.dev/core/v3/jwt"; import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; +import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT"; +import { seedTestRun } from "./helpers/seedTestRun"; +import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint"; vi.setConfig({ testTimeout: 180_000 }); @@ -119,3 +122,306 @@ describe("JWT bearer auth β€” baseline behavior", () => { expect(res.status).toBe(401); }); }); + +// Exercises the RBAC plugin loader end-to-end. The test server boots +// with RBAC_FORCE_FALLBACK=1 (see internal-packages/testcontainers/src/webapp.ts), +// which makes rbac.server.ts use the default fallback regardless of +// whether a plugin is installed in node_modules. /admin/concurrency +// uses rbac.authenticateSession internally; an unauthenticated request +// must flow through LazyController β†’ RoleBaseAccessFallback β†’ +// redirect("/login"). +describe("RBAC plugin β€” fallback wiring", () => { + it("unauthenticated dashboard route redirects to /login via the fallback", async () => { + const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(new URL(location, "http://placeholder").pathname).toBe("/login"); + }); +}); + +// Covers createActionApiRoute's bearer auth path. The target route is +// POST /api/v1/idempotencyKeys/:key/reset β€” allowJWT: true, superScopes: ["write:runs", "admin"]. +// Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps +// authenticateApiRequestWithFailure for rbac.authenticateBearer. +describe("API bearer auth β€” action requests", () => { + const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset"; + + it("valid API key: auth passes (body validation fails, not 401/403)", async () => { + const { apiKey } = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({}), // missing taskIdentifier β†’ zod validation error + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("missing Authorization header: 401", async () => { + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ taskIdentifier: "noop" }), + }); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { + Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real", + "content-type": "application/json", + }, + body: JSON.stringify({ taskIdentifier: "noop" }), + }); + expect(res.status).toBe(401); + }); + +}); + +describe("JWT bearer auth β€” action requests", () => { + const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset"; + + it("JWT with matching scope: auth passes", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] }); + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with wrong scope (read-only) on write route: 403", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] }); + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({ taskIdentifier: "noop" }), + }); + expect(res.status).toBe(403); + }); +}); + +// Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs. +// authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed +// or doesn't match a non-revoked PersonalAccessToken row. +describe("Personal access token auth", () => { + const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`; + + it("missing Authorization header: 401", async () => { + const res = await server.webapp.fetch(pathFor("nonexistent")); + expect(res.status).toBe(401); + }); + + it("API key (tr_dev_*) on PAT-only route: 401", async () => { + const { apiKey } = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + expect(res.status).toBe(401); + }); + + it("malformed PAT (wrong prefix): 401", async () => { + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: "Bearer not_a_pat_at_all_random_string" }, + }); + expect(res.status).toBe(401); + }); + + it("well-formed but unknown PAT: 401", async () => { + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { + Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000", + }, + }); + expect(res.status).toBe(401); + }); + + it("revoked PAT: 401", async () => { + const user = await seedTestUser(server.prisma); + const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true }); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + }); + + it("valid PAT on nonexistent project: 404 (auth passes)", async () => { + const user = await seedTestUser(server.prisma); + const { token } = await seedTestPAT(server.prisma, user.id); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(404); + }); +}); + +// Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource. +// Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete β€” allowJWT: true, +// authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }), +// superScopes: ["write:waitpoints", "admin"] }. +// +// The Waitpoint is seeded with status COMPLETED so the handler short-circuits with +// { success: true } once auth passes β€” no run-engine worker needed. "Auth passes" is +// observable as a 200 response; "auth fails" is observable as a 403. +describe("JWT bearer auth β€” resource-scoped scopes", () => { + const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`; + + async function seedEnvAndWaitpoint() { + const seed = await seedTestEnvironment(server.prisma); + const waitpoint = await seedTestWaitpoint(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + return { ...seed, waitpoint }; + } + + async function completeRequest(friendlyId: string, jwt: string) { + return server.webapp.fetch(pathFor(friendlyId), { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + } + + it("scope matches exact resource id: 200", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: [`write:waitpoints:${waitpoint.friendlyId}`], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("scope targets a different resource id: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); + + it("type-level scope (no id) grants all resources of that type: 200", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: [`read:waitpoints:${waitpoint.friendlyId}`], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); + + it("scope targets a different resource type: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: ["write:runs:run_abc000000000000000000000"], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); + + it("admin super-scope grants access (legacy behaviour): 200", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { scopes: ["admin"] }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("unrelated type scope with no super-scope match: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); +}); + +// Pre-migration coverage for the three behavioural constraints captured in TRI-8719. +// Each test locks in an observable current behaviour that the migration must preserve: +// - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes +// - multi-key resource callbacks (runs/tags/batch/tasks) β€” any key match grants access +// - empty resource callbacks relying on superScopes +describe("JWT bearer auth β€” behaviours to preserve through TRI-8719", () => { + it("custom action: type-level write:tasks scope satisfies action=\"trigger\" (auth passes)", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + // Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks. + // Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"]. + // After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is + // satisfied by a scope whose action is write. + const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] }); + const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("multi-key resource: read:tags: scope grants access to a run carrying that tag (auth passes)", async () => { + const { environment, project } = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: environment.id, + projectId: project.id, + runTags: ["my-resource-scoped-tag"], + }); + const jwt = await generateTestJWT(environment, { + scopes: ["read:tags:my-resource-scoped-tag"], + }); + const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("multi-key resource: read:batch: scope grants access to a run in that batch (auth passes)", async () => { + const { environment, project } = await seedTestEnvironment(server.prisma); + const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, { + environmentId: environment.id, + projectId: project.id, + withBatch: true, + }); + const jwt = await generateTestJWT(environment, { + scopes: [`read:batch:${batchFriendlyId}`], + }); + const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + // Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts) + // currently DENY all JWTs because legacy checkAuthorization's empty-resource check + // fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" } + // changes this to "JWTs with read:runs or write:runs now work on these routes" β€” an + // intentional improvement, not a preserved behaviour. See TRI-8719 description for + // the note; there's nothing to lock in with a test here. +}); + +// Edge cases where auth-path DB state should cause 401 even with a valid-looking token. +describe("API bearer auth β€” environment/project edge cases", () => { + it("valid API key whose project is soft-deleted: 401", async () => { + const { apiKey, project } = await seedTestEnvironment(server.prisma); + await server.prisma.project.update({ + where: { id: project.id }, + data: { deletedAt: new Date() }, + }); + const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts new file mode 100644 index 00000000000..31f2ced7eca --- /dev/null +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -0,0 +1,2974 @@ +// Comprehensive API auth tests β€” uses the shared TestServer started by +// vitest.e2e.full.config.ts's globalSetup. Family subtasks under TRI-8731 +// add nested describe blocks here: +// +// describe("API", () => { +// describe("Trigger task", () => { ... }) // TRI-8733 +// describe("Runs β€” resource routes", () => { ... }) // TRI-8734 +// ... +// }) +// +// See test/helpers/sharedTestServer.ts for `getTestServer()`. + +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; +import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT"; +import { seedTestRun } from "./helpers/seedTestRun"; +import { seedTestApiSession } from "./helpers/seedTestApiSession"; +import { seedTestUserProject } from "./helpers/seedTestUserProject"; +import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint"; + +describe("API", () => { + // Placeholder until family subtasks add their describes (TRI-8733+). + // Verifies the shared container is reachable from this worker. + it("shared webapp container responds to /healthcheck", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch("/healthcheck"); + expect(res.ok).toBe(true); + }); + + // PAT-authenticated routes (TRI-8741). The smoke matrix in + // test/api-auth.e2e.test.ts covers basic 401 cases (missing auth, + // wrong-prefix, unknown PAT, revoked PAT, valid-PAT-on-nonexistent- + // project). This describe extends the matrix to the cases that + // require seeding the full user β†’ org β†’ project β†’ env graph: + // valid-PAT-on-real-project, cross-org isolation, soft-deleted + // project, and the global-admin-flag-doesn't-grant-cross-org carve- + // out. + // + // Target route: GET /api/v1/projects/:projectRef/runs (the only + // createLoaderPATApiRoute consumer at time of writing β€” re-grep + // before extending if more PAT-only routes appear). + describe("PAT-authenticated routes β€” comprehensive", () => { + const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`; + + it("JWT on PAT-only route: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${jwt}` }, + }); + // PAT route doesn't accept JWTs β€” auth rejects before resource lookup. + expect(res.status).toBe(401); + }); + + it("valid PAT, project exists in user's org: auth passes", async () => { + const server = getTestServer(); + const { project, pat } = await seedTestUserProject(server.prisma); + const res = await server.webapp.fetch(pathFor(project.externalRef), { + headers: { Authorization: `Bearer ${pat.token}` }, + }); + // Auth + scoping pass. The route's run-list presenter hits + // ClickHouse which isn't reachable in tests β€” accept any status + // that isn't an auth failure. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("valid PAT, project belongs to a different user's org: 404", async () => { + const server = getTestServer(); + // Two completely isolated graphs. Both projects exist; the PAT + // belongs to userA, the project to userB's org. findProjectByRef + // scopes by `members: { some: { userId } }`, so userA's PAT + // sees userB's project as nonexistent β†’ 404 (not 403). + const a = await seedTestUserProject(server.prisma); + const b = await seedTestUserProject(server.prisma); + const res = await server.webapp.fetch(pathFor(b.project.externalRef), { + headers: { Authorization: `Bearer ${a.pat.token}` }, + }); + // Lock in the 404 β€” the access check inside findProjectByRef + // returns null for cross-org and the route maps null to 404. + expect(res.status).toBe(404); + }); + + it("valid PAT, project soft-deleted (deletedAt != null): 200 (route does not filter)", async () => { + const server = getTestServer(); + // findProjectByRef (apps/webapp/app/models/project.server.ts) + // does NOT filter on deletedAt β€” it scopes only by externalRef + // and the user's org membership. So a soft-deleted project is + // still findable here; the run-list presenter just returns + // data:[] (or whatever survived). The ticket lists this as a + // 404 case but that's not the route's actual contract; lock in + // observed behaviour and call out the gap so a future change + // (either tightening findProjectByRef or filtering at the route) + // is conscious. + const { project, pat } = await seedTestUserProject(server.prisma, { + projectDeleted: true, + }); + const res = await server.webapp.fetch(pathFor(project.externalRef), { + headers: { Authorization: `Bearer ${pat.token}` }, + }); + // ClickHouse-dependent run-list β€” auth-passed assertion. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("valid PAT for a global-admin user: still per-user (no cross-org access)", async () => { + const server = getTestServer(); + // user.admin = true is the legacy super-admin flag. The PAT + // route's access check is per-user (members: { some: { userId } }), + // not admin-aware β€” so admin doesn't unlock cross-org visibility. + // Lock in that behaviour: an admin's PAT can't read another + // org's project either. + const admin = await seedTestUser(server.prisma, { admin: true }); + const adminPat = await seedTestPAT(server.prisma, admin.id); + const otherOrg = await seedTestUserProject(server.prisma); + + const res = await server.webapp.fetch(pathFor(otherOrg.project.externalRef), { + headers: { Authorization: `Bearer ${adminPat.token}` }, + }); + expect(res.status).toBe(404); + }); + + it("valid PAT, admin user accessing their OWN project: auth passes", async () => { + const server = getTestServer(); + // Companion to the above β€” confirm admin=true users can still + // access their own org's projects (the admin flag isn't + // accidentally subtracting permission). + const { project, pat } = await seedTestUserProject(server.prisma, { + userAdmin: true, + }); + const res = await server.webapp.fetch(pathFor(project.externalRef), { + headers: { Authorization: `Bearer ${pat.token}` }, + }); + // ClickHouse-dependent run-list β€” auth-passed assertion. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Resource-scoped writes (TRI-8740). Two routes: + // - POST /api/v1/waitpoints/tokens/:friendlyId/complete + // resource: { type: "waitpoints", id: friendlyId } + // - POST /realtime/v1/streams/:runId/input/:streamId + // resource: { type: "inputStreams", id: runId } + // + // The smoke matrix (api-auth.e2e.test.ts "JWT bearer auth β€” resource- + // scoped scopes") already covers waitpoints comprehensively for JWT + // resource-id matching, type-level scopes, action mismatches, admin + // super-scope, etc. This block fills the gaps: + // - Private API key (not JWT) on the route. + // - JWT with `write:all` super-scope. + // - Cross-env (env A's JWT trying env B's resource). + // Plus the equivalent full matrix for input-streams which the smoke + // matrix doesn't touch. + describe("Resource-scoped writes β€” waitpoints (gap-fill)", () => { + const pathFor = (friendlyId: string) => + `/api/v1/waitpoints/tokens/${friendlyId}/complete`; + const completeRequest = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({}), + }); + + async function seedEnvAndWaitpoint() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const waitpoint = await seedTestWaitpoint(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + return { ...seed, waitpoint }; + } + + it("private API key (tr_dev_*): auth passes (200)", async () => { + const { apiKey, waitpoint } = await seedEnvAndWaitpoint(); + const res = await completeRequest(pathFor(waitpoint.friendlyId), { + Authorization: `Bearer ${apiKey}`, + }); + // Waitpoint is COMPLETED, so the handler short-circuits with 200 + // once auth passes. Auth-passed assertion: NOT 401 / 403. + expect(res.status).toBe(200); + }); + + it("JWT with write:all super-scope: auth passes (200)", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await completeRequest(pathFor(waitpoint.friendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(200); + }); + + it("cross-env: env A's JWT cannot complete env B's waitpoint: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedEnvAndWaitpoint(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`write:waitpoints:${b.waitpoint.friendlyId}`], + }, + expirationTime: "15m", + }); + // The JWT is signed by env A and its sub claim says env A. The + // route resolves env from the sub claim and the waitpoint is + // env B's, so the lookup misses. The exact code depends on + // whether auth or the resource lookup fires first β€” both + // outcomes are correct, just NOT 200. + const res = await completeRequest(pathFor(b.waitpoint.friendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(200); + }); + }); + + describe("Resource-scoped writes β€” input streams (full matrix)", () => { + const pathFor = (runId: string, streamId: string) => + `/realtime/v1/streams/${runId}/input/${streamId}`; + const postRequest = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ data: { hello: "world" } }), + }); + + async function seedEnvAndRun() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + return { ...seed, runFriendlyId, streamId: "test-stream" }; + } + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(pathFor("run_doesnotexist", "stream-x"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: {} }), + }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes (not 401/403)", async () => { + const { apiKey, runFriendlyId, streamId } = await seedEnvAndRun(); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${apiKey}`, + }); + // Route may return any 2xx/4xx based on stream state β€” we only + // care that auth passed (NOT 401/403). + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with exact-id scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`write:inputStreams:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with type-level scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["write:inputStreams"] }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with wrong resource id: 403", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["write:inputStreams:run_someoneelse00000000000000"], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with read action on write route: 403", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:inputStreams:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with write:all super-scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin super-scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("cross-env: env A's JWT cannot write to env B's run: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`write:inputStreams:${b.runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(b.runFriendlyId, b.streamId), { + Authorization: `Bearer ${jwt}`, + }); + // Either auth fails outright or the run lookup misses (env A's + // view of the run doesn't include env B's data). Critical + // security property: NOT 200. + expect(res.status).not.toBe(200); + }); + }); + + // Trigger task routes (TRI-8733). The single-task route uses + // action: "trigger" with a single resource { type: "tasks", id }; + // batch v1/v2 use action: "batchTrigger" with a body-derived array + // [{type:"tasks", id}, ...] under AND semantics β€” every task in the + // batch must be authorized, not just any one (otherwise a JWT scoped + // to one task could submit a batch with arbitrary other tasks). + // v3 batches use a collection-level resource { type: "tasks" } + // (no id β€” items are validated per-row when streamed). + // + // ACTION_ALIASES (from packages/core/src/v3/jwt.ts) maps writeβ†’trigger + // and writeβ†’batchTrigger so write:tasks scopes also satisfy these + // routes. The smoke matrix already verifies write:tasks β†’ trigger + // alias works; we re-test it here per-route so scope misconfig in + // one route doesn't slip past. + describe("Trigger task β€” single (api.v1.tasks.$taskId.trigger)", () => { + const TASK_ID = "test-task"; + const path = `/api/v1/tasks/${TASK_ID}/trigger`; + + async function seedAndRequest( + headers: Record, + body: unknown = { payload: {} } + ) { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + return { res, seed }; + } + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes (handler may 4xx β€” not 401/403)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { + Authorization: `Bearer ${seed.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ payload: {} }), + }); + // Auth passed; the handler may 404 because the task doesn't + // actually exist in the BackgroundWorker. Anything not 401/403 + // is "auth passed" for this test. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:tasks (type-level, ACTION_ALIASES writeβ†’trigger): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with trigger:tasks:: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`trigger:tasks:${TASK_ID}`], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with trigger:tasks:: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["trigger:tasks:some-other-task"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(403); + }); + + it("JWT with read:tasks: 403 (read NOT aliased to trigger)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(403); + }); + + it("JWT with empty scopes: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: [] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(403); + }); + + it("JWT signed with wrong key: 401", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: b.apiKey, // wrong key for env A's sub + payload: { + pub: true, + sub: a.environment.id, + scopes: [`trigger:tasks:${TASK_ID}`], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(401); + }); + + it("JWT with admin super-scope: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Trigger task β€” batch v1 (api.v1.tasks.batch)", () => { + const path = "/api/v1/tasks/batch"; + const buildBody = (taskIds: string[]) => ({ + items: taskIds.map((task) => ({ task, payload: {} })), + }); + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { + Authorization: `Bearer ${seed.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:tasks (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA", "taskB"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with batchTrigger:tasks:taskA + body has [taskA, taskB]: 403 (every-task semantics)", async () => { + // Batch trigger uses AND semantics β€” every task in the body must + // be authorized, not just any one of them. A JWT scoped to only + // taskA cannot submit a batch that also includes taskB, otherwise + // the caller would be triggering tasks they have no scope for. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA", "taskB"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with batchTrigger:tasks:taskA + body has [taskA] only: auth passes", async () => { + // Per-task scope grants per-task access β€” a batch containing + // only the authorized task is allowed. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with batchTrigger:tasks: + body has only taskA: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:not-in-body"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with read:tasks: 403 (action mismatch)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // v2 batch shares the exact same authorization config as v1 β€” same + // body-derived array resource, same batchTrigger action. We don't + // duplicate the full matrix here; the v1 tests cover the wrapper + // behaviour. If v2's authorization config ever diverges from v1's, + // add a targeted test here. For now just sanity-check that the v2 + // route's wiring is alive. + describe("Trigger task β€” batch v2 (api.v2.tasks.batch) sanity", () => { + const path = "/api/v2/tasks/batch"; + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [{ task: "t", payload: {} }] }), + }); + expect(res.status).toBe(401); + }); + + it("JWT with write:tasks: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ items: [{ task: "t", payload: {} }] }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // v3 batches use a collection-level resource { type: "tasks" } with + // no id β€” items are validated per-row when streamed. So id-specific + // scopes (write:tasks:foo) shouldn't grant blanket access; only + // type-level write:tasks (or admin/write:all) should. + describe("Trigger task β€” batch v3 (api.v3.batches) collection-level", () => { + const path = "/api/v3/batches"; + const buildBody = () => ({ runCount: 1 }); + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).toBe(401); + }); + + it("JWT with write:tasks (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:tasks: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Run lists (TRI-8736). Two routes share the same multi-key + // resource pattern β€” collection-level `{ type: "runs" }` always + // present, plus an array of secondary keys derived from search + // params: + // - GET /api/v1/runs: filter[taskIdentifier]=A,B β†’ +{ type: "tasks", id: A }, { type: "tasks", id: B } + // - GET /realtime/v1/runs: ?tags=foo,bar β†’ +{ type: "tags", id: "foo" }, { type: "tags", id: "bar" } + // + // Multi-key any-match contract from TRI-8719: a JWT with a scope + // matching ANY element of the resource array grants access. So: + // - read:runs β†’ matches the collection key β†’ passes + // - read:tasks:A (with A in filter) β†’ matches an array element β†’ passes + // - read:tasks:Z (with A in filter) β†’ no match β†’ 403 + describe("Run list β€” api.v1.runs (multi-key tasks)", () => { + const path = "/api/v1/runs"; + + async function get(query: string, headers: Record) { + return getTestServer().webapp.fetch(`${path}${query}`, { headers }); + } + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + // Pass cases on api.v1.runs assert "auth passed" (not 401/403) + // rather than strict 200. The handler hits ClickHouse which isn't + // reachable from the test container β€” the endpoint can 500 in + // tests even when auth is fine. The auth layer is what we're + // verifying here. + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get("", { Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:runs (collection-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:all super-scope: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with empty scopes: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: [] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT with write:runs (action mismatch β€” read route): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a β†’ passes (array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tasks:task_a"], + }, + expirationTime: "15m", + }); + const res = await get( + "?filter%5BtaskIdentifier%5D=task_a%2Ctask_b", + { Authorization: `Bearer ${jwt}` } + ); + // Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}]. + // The scope read:tasks:task_a matches the second element β†’ access granted. + // Handler may 500 (ClickHouse unreachable in tests) but auth passed. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z β†’ 403 (no array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tasks:task_z"], + }, + expirationTime: "15m", + }); + const res = await get( + "?filter%5BtaskIdentifier%5D=task_a", + { Authorization: `Bearer ${jwt}` } + ); + // Resource is [{runs}, {tasks:task_a}]. JWT scope says + // read:tasks:task_z which doesn't match the runs collection + // (wrong type) or the task_a element (wrong id). 403. + expect(res.status).toBe(403); + }); + }); + + describe("Run list β€” realtime.v1.runs (multi-key tags)", () => { + const path = "/realtime/v1/runs"; + + async function get(query: string, headers: Record) { + return getTestServer().webapp.fetch(`${path}${query}`, { headers }); + } + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("JWT with read:runs (collection-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + // Realtime endpoints stream β€” the route may return 200 (streaming + // OK) or other status codes depending on streams setup. We only + // care that auth passed: NOT 401/403. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:tags:foo + ?tags=foo,bar β†’ passes (array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tags:foo"], + }, + expirationTime: "15m", + }); + const res = await get("?tags=foo,bar", { Authorization: `Bearer ${jwt}` }); + // Resource array is [{type:"runs"}, {type:"tags",id:"foo"}, {type:"tags",id:"bar"}]. + // Scope matches the foo element β†’ access granted. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:tags:baz + ?tags=foo β†’ 403 (no array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tags:baz"], + }, + expirationTime: "15m", + }); + const res = await get("?tags=foo", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + }); + + // Run mutations (TRI-8735). Two routes: + // - POST /api/v2/runs/:runParam/cancel + // action: write, resource: { type: "runs", id: params.runParam } + // β€” single id-keyed resource, supports id-specific scopes. + // - POST /api/v1/idempotencyKeys/:key/reset + // action: write, resource: { type: "runs" } (collection-level) + // β€” id-specific scopes don't grant blanket access; only + // type-level write:runs (or super-scopes) work. + // + // The legacy idempotencyKeys/:key/reset rejected ALL JWTs due to an + // empty-resource bug. Post TRI-8719 the empty-resource resolution + // lets write:runs JWTs through. Tests here lock in the new behaviour. + describe("Run mutations β€” cancel (api.v2.runs.$runParam.cancel)", () => { + const pathFor = (runId: string) => `/api/v2/runs/${runId}/cancel`; + const post = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({}), + }); + + it("missing auth: 401", async () => { + const res = await post(pathFor("run_anything"), {}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await post(pathFor("run_anything"), { + Authorization: "Bearer tr_dev_definitely_not_real_key", + }); + expect(res.status).toBe(401); + }); + + it("private API key on real run: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${seed.apiKey}`, + }); + // Auth + findResource passed; handler may return any 2xx/4xx + // depending on run state. We only care: not 401/403. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs:: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`write:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs:: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["write:runs:run_someoneelse00000000000"], + }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with read:runs (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with write:all super-scope: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Run mutations β€” idempotencyKeys.reset (api.v1.idempotencyKeys.$key.reset)", () => { + // Collection-level resource { type: "runs" } β€” id-specific + // write:runs: scopes don't help here (no id to match). + // The legacy version of this route rejected ALL JWTs due to an + // empty-resource bug; the post-TRI-8719 path lets write:runs + // through. Tests below pin that down. + const path = "/api/v1/idempotencyKeys/some-key/reset"; + const validBody = JSON.stringify({ taskIdentifier: "test-task" }); + + const post = (headers: Record, body = validBody) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body, + }); + + it("missing auth: 401", async () => { + const res = await post({}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await post({ Authorization: "Bearer tr_dev_invalid" }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await post({ Authorization: `Bearer ${seed.apiKey}` }); + // Handler may 404/204 depending on whether the idempotency key + // exists. Auth-passed assertion only. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs (type-level): auth passes β€” locks in TRI-8719 fix", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + // PRE-TRI-8719: this returned 403 (legacy empty-resource bug + // rejected all JWTs). POST-TRI-8719: write:runs grants access. + // Locking in the new behaviour. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:runs (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT with write:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Run resource routes (TRI-8734). Every read-side `$runId` route + // computes its authorization resource from the loaded TaskRun: + // [ + // { type: "runs", id: run.friendlyId }, + // { type: "tasks", id: run.taskIdentifier }, + // ...run.runTags.map(tag => ({ type: "tags", id: tag })), + // run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId }, + // ] + // + // A JWT scope matching ANY array element grants access. We test the + // full matrix against the canonical route (api.v3.runs.$runId), and + // a sanity check on one of the others to confirm the wiring isn't + // route-local. If a future route's resource shape diverges, add a + // targeted describe. + describe("Run resource β€” GET /api/v3/runs/:runId (multi-key array)", () => { + const pathFor = (runId: string) => `/api/v3/runs/${runId}`; + + async function seedRunWithBatchAndTags() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + runTags: ["alpha", "beta"], + withBatch: true, + }); + return { ...seed, ...seeded }; + } + + const get = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await get(pathFor("run_anything"), {}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await get(pathFor("run_anything"), { + Authorization: "Bearer tr_dev_invalid", + }); + expect(res.status).toBe(401); + }); + + it("private API key on real run: auth passes", async () => { + const { runFriendlyId, apiKey } = await seedRunWithBatchAndTags(); + const res = await get(pathFor(runFriendlyId), { + Authorization: `Bearer ${apiKey}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs (type-level): auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs:: auth passes (id match)", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs:: 403", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:runs:run_someoneelse00000000000"], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:tags:: auth passes (array element match)", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + // run was seeded with runTags=["alpha","beta"]; scope matches "alpha". + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:tags:alpha"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:tags:: 403", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:tags:gamma"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:batch:: auth passes", async () => { + const { runFriendlyId, batchFriendlyId, apiKey, environment } = + await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:batch:${batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:batch:: 403", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:batch:batch_someoneelse00000000"], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:tasks:: auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + // seedTestRun uses taskIdentifier "test-task" by default. + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:tasks:test-task"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:all: auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT write:runs:: 403 (action mismatch β€” read route)", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`write:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("cross-env: env A's JWT cannot read env B's run: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`read:runs:${b.runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(b.runFriendlyId), { Authorization: `Bearer ${jwt}` }); + // Either auth fails or the run lookup misses (env A's view of + // the run doesn't include env B's data). Critical: NOT 200. + expect(res.status).not.toBe(200); + }); + }); + + // Sanity check: same multi-key pattern wired the same way on the + // events sub-route. If this drifts in the future the divergence + // gets a dedicated describe. + describe("Run resource β€” GET /api/v1/runs/:runId/events (sanity)", () => { + const pathFor = (runId: string) => `/api/v1/runs/${runId}/events`; + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(pathFor("run_anything")); + expect(res.status).toBe(401); + }); + + it("JWT read:runs (type-level): auth passes on a real run", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await getTestServer().webapp.fetch(pathFor(runFriendlyId), { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Batch resources (TRI-8737). Per-batch retrieve + realtime + // endpoints β€” single-id resource `{ type: "batch", id: batch.friendlyId }`. + // The list endpoint (`GET /api/v1/batches`) is currently absent + // from this branch (deleted in s3-switchover), so the list- + // section of the matrix is N/A here. If/when the list endpoint + // returns, add a list-side describe. + // + // Notable behaviour: the route's resource is `{ type: "batch" }`, + // NOT `{ type: "runs" }`. The legacy literal-match escape that + // let `read:runs` JWTs hit batch endpoints no longer applies. + // Tests pin this down (a `read:runs` scope on a `{ type: "batch" }` + // resource is a type mismatch β†’ 403). + describe("Batch retrieve β€” GET /api/v1/batches/:batchId", () => { + const pathFor = (batchId: string) => `/api/v1/batches/${batchId}`; + + async function seedRunWithBatch() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + withBatch: true, + }); + // batchFriendlyId is guaranteed when withBatch is set. + if (!seeded.batchFriendlyId) { + throw new Error("seedTestRun({ withBatch: true }) didn't return a batchFriendlyId"); + } + return { ...seed, batchFriendlyId: seeded.batchFriendlyId }; + } + + const get = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await get(pathFor("batch_anything"), {}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await get(pathFor("batch_anything"), { + Authorization: "Bearer tr_dev_invalid", + }); + expect(res.status).toBe(401); + }); + + it("private API key on real batch: auth passes", async () => { + const { batchFriendlyId, apiKey } = await seedRunWithBatch(); + const res = await get(pathFor(batchFriendlyId), { + Authorization: `Bearer ${apiKey}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:batch: matching: auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:batch:${batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:batch:: 403", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:batch:batch_someoneelse00000000"], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:batch (type-level): auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:batch"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs: 403 (resource type is 'batch', not 'runs')", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + // Pre-TRI-8719 the legacy literal-match escape granted + // read:runs access to batch endpoints. Post-migration the + // resource type is strictly { type: "batch" } and read:runs + // doesn't match. Lock this in β€” if SDKs were issuing + // read:runs:* JWTs for batch lookups, that's a regression to + // catch. + expect(res.status).toBe(403); + }); + + it("JWT read:all super-scope: auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("cross-env: env A's JWT cannot read env B's batch: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`read:batch:${b.batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(b.batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + // Critical: env A's JWT can't see env B's batch (env-scoped + // findResource returns null). NOT 200. + expect(res.status).not.toBe(200); + }); + }); + + // Sanity: api.v2 and realtime.v1 share the exact same authorization + // config as v1. Don't duplicate the full matrix; just verify the + // wiring is alive on each. + describe("Batch retrieve β€” GET /api/v2/batches/:batchId (sanity)", () => { + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch("/api/v2/batches/batch_anything"); + expect(res.status).toBe(401); + }); + + it("JWT read:batch (type-level): auth passes on real batch", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + withBatch: true, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:batch"] }, + expirationTime: "15m", + }); + const res = await getTestServer().webapp.fetch( + `/api/v2/batches/${seeded.batchFriendlyId}`, + { headers: { Authorization: `Bearer ${jwt}` } } + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Prompts routes (TRI-8738). Resource shapes: + // - List resource: { type: "prompts", id: "all" } action: read + // - Retrieve resource: { type: "prompts", id: params.slug } action: read + // - Override resource: { type: "prompts", id: params.slug } action: update + // (multi-method: POST/PUT/PATCH/DELETE) + // - Promote resource: { type: "prompts", id: params.slug } action: update + // - Reactivate resource: { type: "prompts", id: params.slug } action: update + // + // ACTION_ALIASES: update ← write, so write:prompts also satisfies + // the update-action routes. + // + // Auth happens before any DB lookup, so we test against + // non-existent slugs β€” handler will 404 but we assert "not 401/403" + // for pass cases. + describe("Prompts list β€” GET /api/v1/prompts (collection-level)", () => { + const path = "/api/v1/prompts"; + const get = (headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get({ Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs: 403 (type mismatch)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Prompts retrieve β€” GET /api/v1/prompts/:slug (id-keyed read)", () => { + const SLUG = "test-prompt"; + const path = `/api/v1/prompts/${SLUG}`; + const get = (headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get({ Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts:: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:prompts:${SLUG}`], + }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts:: not 200 (no access)", async () => { + // Note: the prompts retrieve route has a findResource callback + // that runs BEFORE authorization. Since we don't seed a Prompt + // fixture, the route 404s before reaching the auth check β€” + // assert "not 200" to capture the no-access semantic without + // depending on whether the guard that fires first is auth (403) + // or findResource (404). Both block the user. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:prompts:some-other-slug"], + }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(200); + }); + + it("JWT read:runs: not 200 (type mismatch β€” no access)", async () => { + // Same caveat as above re: findResource ordering. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(200); + }); + + it("JWT admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Prompts override β€” POST /api/v1/prompts/:slug/override (update action)", () => { + const SLUG = "test-prompt"; + const path = `/api/v1/prompts/${SLUG}/override`; + const post = (headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ content: "test" }), + }); + + it("missing auth: 401", async () => { + const res = await post({}); + expect(res.status).toBe(401); + }); + + it("JWT write:prompts: matching (ACTION_ALIASES writeβ†’update): passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`write:prompts:${SLUG}`], + }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT write:prompts (type-level): passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts: 403 (action mismatch β€” read NOT aliased to update)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:prompts:${SLUG}`], + }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT write:prompts:: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["write:prompts:some-other-slug"], + }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT admin: passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Prompts promote/reactivate (sanity, update action)", () => { + it("promote: JWT write:prompts (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch("/api/v1/prompts/some-slug/promote", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({}), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("reactivate: JWT read:prompts: 403 (action mismatch)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] }, + expirationTime: "15m", + }); + // Body must satisfy the route's schema ({ version: positive int }) + // β€” otherwise body validation 400s before authorization runs. + const res = await server.webapp.fetch( + "/api/v1/prompts/some-slug/override/reactivate", + { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ version: 1 }), + } + ); + expect(res.status).toBe(403); + }); + }); + + // Deployments + query routes (TRI-8739). Read-only family with + // distinct resource types per route: + // - GET /api/v1/deployments { type: "deployments", id: "list" } + // - GET /api/v1/query/schema { type: "query", id: "schema" } + // - GET /api/v1/query/dashboards { type: "query", id: "dashboards" } + // - POST /api/v1/query body-derived: detectTables(query) β†’ + // [{ type: "query", id }] or + // { type: "query", id: "all" } if none + describe("Deployments list β€” GET /api/v1/deployments", () => { + const path = "/api/v1/deployments"; + const get = (headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get({ Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:deployments: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs (type mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT write:deployments (action mismatch β€” read route): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:deployments"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + }); + + describe("Query schema β€” GET /api/v1/query/schema (sanity)", () => { + const path = "/api/v1/query/schema"; + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("JWT read:query (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:deployments (type mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).toBe(403); + }); + }); + + describe("Query dashboards β€” GET /api/v1/query/dashboards (sanity)", () => { + const path = "/api/v1/query/dashboards"; + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("JWT read:query: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Query ad-hoc β€” POST /api/v1/query (body-derived resource)", () => { + const path = "/api/v1/query"; + const post = (body: object, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + + it("missing auth: 401", async () => { + const res = await post({ query: "SELECT * FROM runs" }, {}); + expect(res.status).toBe(401); + }); + + it("body with table 'runs' + JWT read:query:runs: auth passes (any-match)", async () => { + // detectTables pulls 'runs' from FROM-clause. Resource becomes + // [{ type: "query", id: "runs" }]. Scope read:query:runs matches. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT * FROM runs" }, { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("body with no detectable tables (defaults id='all') + JWT read:query: auth passes", async () => { + // A query with no FROM clause β†’ detectTables returns [] β†’ + // resource is { type: "query", id: "all" }. Type-level read:query + // matches. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("multi-table body + JWT scoped to only one of them: 403 (every-table semantics)", async () => { + // detectTables matches `\bFROM\s+\b` per query-schema, so + // a query with two FROM clauses (e.g. UNION) yields a multi- + // entry resource list. The route wraps it in everyResource so + // AND semantics apply: a JWT scoped to one detected table + // cannot submit a query that also reads the other. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] }, + expirationTime: "15m", + }); + const res = await post( + { + query: + "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics", + }, + { Authorization: `Bearer ${jwt}` } + ); + expect(res.status).toBe(403); + }); + + it("multi-table body + JWT scoped to all detected tables: auth passes", async () => { + // Companion to the every-table 403 above β€” when the JWT covers + // every detected table the AND-check passes. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:query:runs", "read:query:metrics"], + }, + expirationTime: "15m", + }); + const res = await post( + { + query: + "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics", + }, + { Authorization: `Bearer ${jwt}` } + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("body with table 'runs' + JWT read:query:other_table: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:query:other_table"], + }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT * FROM runs" }, { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT admin: auth passes regardless of body", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT * FROM runs" }, { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT write:query (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:query"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + }); + + describe("Batch retrieve β€” GET /realtime/v1/batches/:batchId (sanity)", () => { + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch("/realtime/v1/batches/batch_anything"); + expect(res.status).toBe(401); + }); + + it("JWT read:batch:: auth passes on real batch", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + withBatch: true, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:batch:${seeded.batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await getTestServer().webapp.fetch( + `/realtime/v1/batches/${seeded.batchFriendlyId}`, + { headers: { Authorization: `Bearer ${jwt}` } } + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Sessions β€” JWT scope matrix. + // + // The session routes were authored against the pre-RBAC apiBuilder + // and used the legacy `superScopes: [...]` field to whitelist broad + // access. After TRI-8719 superScopes is dead code; the equivalent + // bypass is expressed via: + // - multi-key resource arrays (one element per addressable key, + // plus a collection-level `{ type: "sessions" }` for type-only + // scopes) + // - the JWT ability's `*:all` and `admin*` wildcard branches + // + // These tests lock in that the migration's "no JWT regresses" + // promise holds for sessions. Each historical superScope becomes a + // positive test, and per-task narrowing gets negative coverage. + describe("Sessions β€” JWT scope matrix", () => { + // ---- List sessions: GET /api/v1/sessions + // + // Resource: [{ type: "tasks", id: } per filter id, { type: "sessions" }] + // Old superScopes: ["read:sessions", "read:all", "admin"] + describe("List sessions β€” GET /api/v1/sessions", () => { + const path = (taskFilter?: string) => + taskFilter + ? `/api/v1/sessions?filter[taskIdentifier]=${taskFilter}` + : "/api/v1/sessions"; + + const fetchWithJwt = async (jwt: string, taskFilter?: string) => + getTestServer().webapp.fetch(path(taskFilter), { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + // The handler reads from ClickHouse via SessionsRepository, which + // isn't wired up in the e2e webapp container β€” so successful auth + // surfaces as 5xx after the handler errors. Assert "not 401, not + // 403" rather than 200 for the auth-passes paths. + + it("read:tasks:foo on filter=foo: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks:foo", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:tasks:bar on filter=foo: 403 (per-task narrowing)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks:bar", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).toBe(403); + }); + + it("read:sessions on filter=foo: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:sessions on no-filter list: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await fetchWithJwt(jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:all: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("admin: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:tasks (type-only) on no-filter list: 403 (filter is sessions, not tasks)", async () => { + // No filter β†’ resource is `{ type: "sessions" }` only. read:tasks + // doesn't match the sessions type, so 403 β€” explicit narrowing. + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks", + ]); + const res = await fetchWithJwt(jwt); + expect(res.status).toBe(403); + }); + + it("write:tasks:foo (wrong action) on filter=foo: 403", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:tasks:foo", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).toBe(403); + }); + }); + + // ---- Create session: POST /api/v1/sessions + // + // Resource: [{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }] + // Old superScopes: ["write:sessions", "admin"] + describe("Create session β€” POST /api/v1/sessions", () => { + const path = "/api/v1/sessions"; + + const post = async (jwt: string, taskIdentifier: string) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "chat.agent", + taskIdentifier, + triggerConfig: { basePayload: {} }, + }), + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("write:tasks:foo matching body: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:tasks:foo", + ]); + const res = await post(jwt, "foo"); + // Body validation / handler can fail later (404 if task is + // missing, 400 for invalid body) β€” we only care that auth + // didn't reject. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:tasks:bar mismatching body: 403", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:tasks:bar", + ]); + const res = await post(jwt, "foo"); + expect(res.status).toBe(403); + }); + + it("write:sessions: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await post(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:all: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]); + const res = await post(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("admin: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await post(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:tasks:foo (wrong action): 403", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks:foo", + ]); + const res = await post(jwt, "foo"); + expect(res.status).toBe(403); + }); + }); + + // ---- Retrieve session: GET /api/v1/sessions/:session + // + // Resource: multi-key array of `{ type: "sessions", id }` entries + // for friendlyId and externalId (when set). + // Old superScopes: ["read:sessions", "read:all", "admin"] + describe("Retrieve session β€” GET /api/v1/sessions/:session", () => { + const get = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("read:sessions:: 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + `read:sessions:${session.friendlyId}`, + ]); + const res = await get(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("read:sessions: on externalId URL form: 200 (multi-key)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + `read:sessions:${session.externalId}`, + ]); + const res = await get(session.externalId!, jwt); + expect(res.status).toBe(200); + }); + + it("read:sessions (type-only): 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await get(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("read:sessions:other (non-matching id): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions:not-this-session", + ]); + const res = await get(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- Update session: PATCH /api/v1/sessions/:session + // + // action: "admin" β€” only admin-tier scopes (or wildcards) satisfy. + // Old superScopes: ["admin:sessions", "admin:all", "admin"] + describe("Update session β€” PATCH /api/v1/sessions/:session", () => { + const patch = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ tags: ["updated"] }), + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("admin:sessions:: 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + `admin:sessions:${session.friendlyId}`, + ]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("admin:sessions (type-only): 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "admin:sessions", + ]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("admin:all: 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin:all"]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("admin (bare): 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("write:sessions (wrong action β€” admin not aliased from write): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- Close session: POST /api/v1/sessions/:session/close + // + // action: "admin" β€” same matrix as PATCH. + describe("Close session β€” POST /api/v1/sessions/:session/close", () => { + const close = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch( + `/api/v1/sessions/${sessionParam}/close`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ reason: "test" }), + } + ); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("admin:sessions: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "admin:sessions", + ]); + const res = await close(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await close(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:sessions: 403 (admin action)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await close(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- End-and-continue: POST /api/v1/sessions/:session/end-and-continue + // + // action: "write" β€” multi-key sessions resource. + describe("End-and-continue β€” POST /api/v1/sessions/:session/end-and-continue", () => { + const endAndContinue = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch( + `/api/v1/sessions/${sessionParam}/end-and-continue`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + // Body shape doesn't matter for auth β€” handler runs after + // the auth check so any 4xx here means auth passed. + body: JSON.stringify({ + reason: "test", + callingRunId: "run_does_not_exist", + }), + } + ); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("write:sessions: auth passes (was a superScope)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await endAndContinue(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]); + const res = await endAndContinue(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:sessions (wrong action): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await endAndContinue(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- Realtime IO: GET (subscribe) and PUT (initialize) + // + // Both go through createLoaderApiRoute / createActionApiRoute β€” same + // multi-key sessions resource. No deep matrix here; one positive + // test per old superScope per method is enough. + describe("Realtime IO β€” /realtime/v1/sessions/:session/:io", () => { + const ioPath = (sessionParam: string) => + `/realtime/v1/sessions/${sessionParam}/in`; + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("GET with read:sessions (was a superScope): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await server.webapp.fetch(ioPath(session.friendlyId), { + method: "HEAD", + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("GET with read:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]); + const res = await server.webapp.fetch(ioPath(session.friendlyId), { + method: "HEAD", + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("PUT with write:sessions (was a superScope): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await server.webapp.fetch(ioPath(session.friendlyId), { + method: "PUT", + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // ---- Realtime append: POST /realtime/v1/sessions/:session/:io/append + // + // action: "write" β€” multi-key sessions resource. + describe("Realtime append β€” POST /realtime/v1/sessions/:session/:io/append", () => { + const appendPath = (sessionParam: string) => + `/realtime/v1/sessions/${sessionParam}/in/append`; + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("write:sessions (was a superScope): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await server.webapp.fetch(appendPath(session.friendlyId), { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/octet-stream", + }, + body: new Uint8Array([1, 2, 3]), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]); + const res = await server.webapp.fetch(appendPath(session.friendlyId), { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/octet-stream", + }, + body: new Uint8Array([1, 2, 3]), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + }); +}); diff --git a/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts b/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts new file mode 100644 index 00000000000..d5d462f6c32 --- /dev/null +++ b/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts @@ -0,0 +1,216 @@ +// Cross-cutting auth-layer behaviours that aren't tied to a specific route +// family β€” see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs, +// cross-env mismatch, force-fallback toggle. +// +// Strategy: pick one representative API-key route +// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT +// route (POST /api/v1/waitpoints/tokens//complete) and exercise the +// edge cases against those. The route choice doesn't matter β€” the +// auth layer is shared across every API route via apiBuilder.server.ts. +// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial +// cases (missing/invalid key, basic JWT pass, soft-deleted project); +// this file adds cases that need explicit fixture setup. + +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import { SignJWT } from "jose"; +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; + +describe("Cross-cutting", () => { + it("shared prisma client can read from the postgres container", async () => { + const server = getTestServer(); + const count = await server.prisma.user.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + // The auth path falls back to RevokedApiKey when a key isn't found + // in RuntimeEnvironment β€” letting customers continue to use a key + // for a configurable grace window after rotation. See + // models/runtimeEnvironment.server.ts. The grace lookup matches by + // (apiKey AND expiresAt > now) and rehydrates the env via the FK. + describe("Revoked API key grace window", () => { + const route = "/api/v1/runs/run_doesnotexist/result"; + + it("revoked key within grace (expiresAt > now): auth passes", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + // Mint a fresh "rotated" key that doesn't exist on any env, then + // record it as recently revoked with a future grace expiry. + const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`; + await server.prisma.revokedApiKey.create({ + data: { + apiKey: rotatedKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day + }, + }); + const res = await server.webapp.fetch(route, { + headers: { Authorization: `Bearer ${rotatedKey}` }, + }); + // Auth passed β€” the route's resource lookup just doesn't find + // run_doesnotexist. The point is NOT 401. + expect(res.status).not.toBe(401); + }); + + it("revoked key past grace (expiresAt < now): 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`; + await server.prisma.revokedApiKey.create({ + data: { + apiKey: expiredKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute + }, + }); + const res = await server.webapp.fetch(route, { + headers: { Authorization: `Bearer ${expiredKey}` }, + }); + expect(res.status).toBe(401); + }); + }); + + // JWT edge cases beyond what the smoke matrix covers (which only + // checks "wrong key" and "missing scope"). All target the same + // representative JWT route β€” the JWT validator is shared across + // routes via apiBuilder, so coverage here generalises. + describe("JWT edge cases", () => { + const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete"; + + async function postWithJwt(jwt: string) { + const server = getTestServer(); + return server.webapp.fetch(route, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + } + + it("JWT with expirationTime in the past: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + // generateJWT only accepts string expirationTimes (relative, like + // "15m"). To create a definitively-expired token use jose + // directly with an absolute past timestamp. + const secret = new TextEncoder().encode(environment.apiKey); + const jwt = await new SignJWT({ + pub: true, + sub: environment.id, + scopes: ["write:waitpoints"], + }) + .setIssuer("https://id.trigger.dev") + .setAudience("https://api.trigger.dev") + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(0) + .setExpirationTime(1) // 1970-01-01 β€” definitively expired + .sign(secret); + + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT with pub: false: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] }, + expirationTime: "15m", + }); + // pub: false means "this token isn't meant for client-side use" + // β€” the auth layer rejects it for the same-class JWT routes. + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT with no sub claim: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, scopes: ["write:waitpoints"] }, + expirationTime: "15m", + }); + // No sub claim β€” auth can't resolve which env the token belongs + // to, so it must reject. (sub carries the env id.) + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT signed with another env's apiKey (cross-env): 401", async () => { + const server = getTestServer(); + // env A's id but signed with env B's apiKey β€” sub-vs-signature + // mismatch the auth layer must catch. + const a = await seedTestEnvironment(server.prisma); + const b = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: b.apiKey, // <-- WRONG key relative to the sub claim + payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] }, + expirationTime: "15m", + }); + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT malformed (three parts but invalid base64 in payload): 401", async () => { + // Three "."-separated parts so the JWT shape gate sees it as a + // candidate, but the payload segment is non-base64 garbage. + // Validator must surface this as 401, not 500. + const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature"; + const res = await postWithJwt(malformed); + expect(res.status).toBe(401); + }); + }); + + // The auth layer resolves the JWT's env from the `sub` claim β€” NOT + // from the route path. So a JWT for env A hitting a route that + // fetches a resource from env B should never accidentally see env + // B's data. Test by minting a JWT for env A and asking for a + // resource that lives in env B β€” expect 404 (not 200). + describe("Cross-environment: JWT auth resolves env from sub, not URL", () => { + it("env A's JWT cannot read env B's resource: 404", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedTestEnvironment(server.prisma); + + // Seed a real-ish run row in env B so the route would have + // something to find IF auth resolved the env from the URL. + const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`; + await server.prisma.taskRun.create({ + data: { + friendlyId, + taskIdentifier: "test-task", + payload: "{}", + payloadType: "application/json", + traceId: `trace_${Math.random().toString(36).slice(2)}`, + spanId: `span_${Math.random().toString(36).slice(2)}`, + runtimeEnvironmentId: b.environment.id, + projectId: b.project.id, + organizationId: b.organization.id, + engine: "V2", + status: "COMPLETED_SUCCESSFULLY", + queue: "task/test-task", + }, + }); + + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + + const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + // The route resolves runs scoped to the JWT's env (env A). The + // run lives in env B, so env A's view returns "not found" β€” + // critically, NOT 200. + expect(res.status).not.toBe(200); + expect([401, 404]).toContain(res.status); + }); + }); +}); diff --git a/apps/webapp/test/auth-dashboard.e2e.full.test.ts b/apps/webapp/test/auth-dashboard.e2e.full.test.ts new file mode 100644 index 00000000000..948b9c6c0cf --- /dev/null +++ b/apps/webapp/test/auth-dashboard.e2e.full.test.ts @@ -0,0 +1,122 @@ +// Comprehensive dashboard session-auth tests β€” see TRI-8742. +// Each test seeds a User + session cookie via seedTestUser / seedTestSession +// (helpers/seedTestSession.ts) and hits the shared webapp container. + +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestSession, seedTestUser } from "./helpers/seedTestSession"; + +describe("Dashboard", () => { + it("shared webapp container redirects /admin/concurrency to /login when unauthenticated", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" }); + expect(res.status).toBe(302); + }); + + // Admin pages migrated to dashboardLoader({ authorization: { requireSuper: true } }) + // in TRI-8717. The dashboardLoader resolves auth in three stages: + // 1. No session β†’ redirect to /login?redirectTo=. + // 2. Session, user.admin === false β†’ redirect to / (no path leakage). + // 3. Session, user.admin === true β†’ run the loader handler. + // + // Coverage strategy: pick three representative routes (the index, a + // tabbed sub-page, and the back-office tree) rather than all 14 β€” + // they all share the same dashboardLoader config so testing every + // file would just confirm the wrapper works, which the harness + // already proves. If the wrapper config drifts per-route in the + // future, add targeted tests for the divergent ones. + describe("Admin pages β€” requireSuper gate", () => { + const adminRoutes = [ + "/admin", + "/admin/concurrency", + "/admin/back-office", + ]; + + for (const path of adminRoutes) { + describe(`GET ${path}`, () => { + it("no session: redirects to /login?redirectTo=", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { redirect: "manual" }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/login"); + // Path leaks deliberately so a successful login bounces the + // user back to where they were headed. + expect(location).toContain(`redirectTo=${encodeURIComponent(path)}`); + }); + + it("session for non-admin user: redirects to / (no path leakage)", async () => { + const server = getTestServer(); + const user = await seedTestUser(server.prisma, { admin: false }); + const cookie = await seedTestSession({ userId: user.id }); + const res = await server.webapp.fetch(path, { + redirect: "manual", + headers: { Cookie: cookie }, + }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + // unauthorizedRedirect default in dashboardBuilder is "/". + // A non-admin landing on /admin shouldn't get redirectTo + // back to /admin once they upgrade β€” they're not getting in + // by re-auth. + expect(new URL(location, "http://localhost").pathname).toBe("/"); + }); + + it("session for admin user: 2xx", async () => { + const server = getTestServer(); + const user = await seedTestUser(server.prisma, { admin: true }); + const cookie = await seedTestSession({ userId: user.id }); + const res = await server.webapp.fetch(path, { + redirect: "manual", + headers: { Cookie: cookie }, + }); + // Loader handler ran β€” could be 200 (HTML) or 204 (Remix + // _data fetch). Either way, NOT a redirect. + expect(res.status).toBeLessThan(300); + }); + }); + } + }); + + // Action handlers behind requireSuper used to return 403 Unauthorized + // pre-RBAC β€” now they redirect to / via dashboardAction's + // unauthorizedRedirect. The ticket flagged this as a behaviour + // change worth locking in (any XHR fetcher that branched on 403 + // would have regressed silently). Use admin.feature-flags POST as + // the canary β€” it's the simplest action of the bunch. + describe("Admin action β€” requireSuper gate (admin.feature-flags POST)", () => { + const path = "/admin/feature-flags"; + + it("no session: redirects to /login (POST)", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + redirect: "manual", + }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/login"); + }); + + it("session for non-admin user: redirects to / (was 403 pre-RBAC)", async () => { + const server = getTestServer(); + const user = await seedTestUser(server.prisma, { admin: false }); + const cookie = await seedTestSession({ userId: user.id }); + const res = await server.webapp.fetch(path, { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json", Cookie: cookie }, + redirect: "manual", + }); + // Behaviour change from the TRI-8717 migration: the legacy + // path returned 403 Unauthorized; dashboardAction returns a + // 302 to "/" instead. Any client code branching on 403 needs + // updating β€” locking this in so a silent regression is loud. + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(new URL(location, "http://localhost").pathname).toBe("/"); + }); + }); +}); diff --git a/apps/webapp/test/helpers/seedTestApiSession.ts b/apps/webapp/test/helpers/seedTestApiSession.ts new file mode 100644 index 00000000000..cb98c1798c9 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestApiSession.ts @@ -0,0 +1,47 @@ +// Inserts a `Session` row directly via Prisma so route auth tests can +// exercise routes that resolve a session by friendlyId or externalId. +// +// Note: not to be confused with `seedTestSession` in this directory β€” +// that helper builds a *dashboard cookie session* for cookie-auth tests. +// This helper builds an *agent-stream Session row* (the chat.agent +// runtime concept). + +import type { PrismaClient, Session } from "@trigger.dev/database"; +import { randomBytes } from "node:crypto"; + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len); +} + +export async function seedTestApiSession( + prisma: PrismaClient, + env: { + id: string; + type: string; + organizationId: string; + projectId: string; + }, + overrides?: { taskIdentifier?: string; externalId?: string | null } +): Promise { + const suffix = randomHex(8); + return prisma.session.create({ + data: { + id: `session_${suffix}`, + friendlyId: `session_${suffix}`, + // `null` lets a caller exercise the externalId-absent code path + // (single-id auth resource); omit the override to get a unique + // externalId for the multi-key path. + externalId: + overrides?.externalId === null + ? null + : overrides?.externalId ?? `ext_${suffix}`, + type: "chat.agent", + projectId: env.projectId, + runtimeEnvironmentId: env.id, + environmentType: env.type as Session["environmentType"], + organizationId: env.organizationId, + taskIdentifier: overrides?.taskIdentifier ?? `agent_${suffix}`, + triggerConfig: { basePayload: { messages: [], trigger: "preload" } }, + }, + }); +} diff --git a/apps/webapp/test/helpers/seedTestPAT.ts b/apps/webapp/test/helpers/seedTestPAT.ts new file mode 100644 index 00000000000..d977bf5882e --- /dev/null +++ b/apps/webapp/test/helpers/seedTestPAT.ts @@ -0,0 +1,59 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { createCipheriv, createHash, randomBytes } from "node:crypto"; + +// Must match ENCRYPTION_KEY in internal-packages/testcontainers/src/webapp.ts +const ENCRYPTION_KEY = "test-encryption-key-for-e2e!!!!!"; + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function encryptToken(value: string, key: string) { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + let encrypted = cipher.update(value, "utf8", "hex"); + encrypted += cipher.final("hex"); + return { + nonce: nonce.toString("hex"), + ciphertext: encrypted, + tag: cipher.getAuthTag().toString("hex"), + }; +} + +function obfuscate(token: string): string { + return `${token.slice(0, 11)}${"β€’".repeat(20)}${token.slice(-4)}`; +} + +export async function seedTestUser(prisma: PrismaClient, overrides?: { admin?: boolean }) { + const suffix = randomBytes(6).toString("hex"); + return prisma.user.create({ + data: { + email: `pat-user-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + admin: overrides?.admin ?? false, + }, + }); +} + +// Seeds a PersonalAccessToken row using the same hashing/encryption scheme as +// webapp's services/personalAccessToken.server.ts so the webapp subprocess can +// authenticate against it. +export async function seedTestPAT( + prisma: PrismaClient, + userId: string, + opts: { revoked?: boolean } = {} +): Promise<{ token: string; id: string }> { + const token = `tr_pat_${randomBytes(20).toString("hex")}`; + const encrypted = encryptToken(token, ENCRYPTION_KEY); + const row = await prisma.personalAccessToken.create({ + data: { + name: "e2e-test-pat", + userId, + encryptedToken: encrypted, + hashedToken: hashToken(token), + obfuscatedToken: obfuscate(token), + revokedAt: opts.revoked ? new Date() : null, + }, + }); + return { token, id: row.id }; +} diff --git a/apps/webapp/test/helpers/seedTestRun.ts b/apps/webapp/test/helpers/seedTestRun.ts new file mode 100644 index 00000000000..44137e45005 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestRun.ts @@ -0,0 +1,61 @@ +import type { PrismaClient, TaskRun } from "@trigger.dev/database"; +import { customAlphabet, nanoid } from "nanoid"; + +const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21); + +export interface SeededRun { + run: TaskRun; + runFriendlyId: string; // `run_...` + batchFriendlyId?: string; // `batch_...` when { withBatch: true } +} + +// Minimum-viable TaskRun for auth-layer e2e tests β€” enough fields for +// ApiRetrieveRunPresenter.findRun to return it and for the authorization.resource +// callback to populate `runs`, `tags`, `batch`, `tasks` keys. +export async function seedTestRun( + prisma: PrismaClient, + opts: { + environmentId: string; + projectId: string; + runTags?: string[]; + withBatch?: boolean; + } +): Promise { + const runInternalId = idGenerator(); + const runFriendlyId = `run_${runInternalId}`; + + let batchInternalId: string | undefined; + if (opts.withBatch) { + batchInternalId = idGenerator(); + await prisma.batchTaskRun.create({ + data: { + id: batchInternalId, + friendlyId: `batch_${batchInternalId}`, + runtimeEnvironmentId: opts.environmentId, + }, + }); + } + + const run = await prisma.taskRun.create({ + data: { + id: runInternalId, + friendlyId: runFriendlyId, + taskIdentifier: "test-task", + payload: "{}", + payloadType: "application/json", + traceId: nanoid(32), + spanId: nanoid(16), + queue: "task/test-task", + runtimeEnvironmentId: opts.environmentId, + projectId: opts.projectId, + runTags: opts.runTags ?? [], + batchId: batchInternalId, + }, + }); + + return { + run, + runFriendlyId, + batchFriendlyId: batchInternalId ? `batch_${batchInternalId}` : undefined, + }; +} diff --git a/apps/webapp/test/helpers/seedTestSession.ts b/apps/webapp/test/helpers/seedTestSession.ts new file mode 100644 index 00000000000..3e51c5c2c63 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestSession.ts @@ -0,0 +1,58 @@ +// Produces a `Cookie:` header value for an authenticated session that the +// webapp under test will accept. Mirrors the webapp's +// `services/sessionStorage.server.ts` config exactly β€” the SESSION_SECRET +// must match what the webapp container was started with (see +// `internal-packages/testcontainers/src/webapp.ts` β€” currently +// "test-session-secret-for-e2e-tests"). +// +// Used by dashboard auth tests (TRI-8742). Each test seeds its own user + +// session so test order doesn't matter. + +import { createCookieSessionStorage } from "@remix-run/node"; +import type { PrismaClient } from "@trigger.dev/database"; +import { randomBytes } from "node:crypto"; + +// Must match SESSION_SECRET in internal-packages/testcontainers/src/webapp.ts. +const SESSION_SECRET = "test-session-secret-for-e2e-tests"; + +// Shape of the session config in apps/webapp/app/services/sessionStorage.server.ts. +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: [SESSION_SECRET], + secure: false, // NODE_ENV is "test" in the spawned webapp. + maxAge: 60 * 60 * 24 * 365, + }, +}); + +export async function seedTestUser( + prisma: PrismaClient, + overrides?: { admin?: boolean; email?: string } +) { + const suffix = randomBytes(6).toString("hex"); + return prisma.user.create({ + data: { + email: overrides?.email ?? `e2e-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + admin: overrides?.admin ?? false, + }, + }); +} + +// Builds the `Cookie:` header value for a given user. Set this on test +// requests to the webapp to authenticate as that user. +// +// remix-auth's default sessionKey is "user" and stores AuthUser as +// { userId } β€” see apps/webapp/app/services/authUser.ts. +export async function seedTestSession(opts: { userId: string }): Promise { + const session = await sessionStorage.getSession(); + session.set("user", { userId: opts.userId }); + const setCookie = await sessionStorage.commitSession(session); + // commitSession returns "__session=; Path=/; ...". The Cookie + // header only needs the name=value pair. + const firstSegment = setCookie.split(";")[0]; + return firstSegment; +} diff --git a/apps/webapp/test/helpers/seedTestUserProject.ts b/apps/webapp/test/helpers/seedTestUserProject.ts new file mode 100644 index 00000000000..3512054ec1f --- /dev/null +++ b/apps/webapp/test/helpers/seedTestUserProject.ts @@ -0,0 +1,67 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { randomBytes } from "node:crypto"; +import { seedTestPAT, seedTestUser } from "./seedTestPAT"; + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len); +} + +// Composite test fixture: a User, an Organization with that user as a +// member, a Project owned by the org, a DEVELOPMENT environment, and a +// non-revoked PAT for the user. +// +// Used by the PAT-comprehensive matrix (TRI-8741) to exercise routes +// like GET /api/v1/projects/:projectRef/runs whose access check is +// `findProjectByRef(externalRef, userId)` β€” i.e. the project's org +// must have the userId in its members. seedTestEnvironment alone +// doesn't create the OrgMember link, which is why this helper exists. +// +// Caller passes `projectDeleted: true` to test the soft-deleted- +// project path; `userAdmin: true` to confirm the global admin flag +// doesn't add cross-org visibility (the route is per-user). +export async function seedTestUserProject( + prisma: PrismaClient, + opts: { userAdmin?: boolean; projectDeleted?: boolean } = {} +) { + const suffix = randomHex(8); + const apiKey = `tr_dev_${randomHex(24)}`; + const pkApiKey = `pk_dev_${randomHex(24)}`; + + const user = await seedTestUser(prisma, { admin: opts.userAdmin ?? false }); + + const organization = await prisma.organization.create({ + data: { + title: `e2e-pat-org-${suffix}`, + slug: `e2e-pat-org-${suffix}`, + v3Enabled: true, + members: { create: { userId: user.id, role: "ADMIN" } }, + }, + }); + + const project = await prisma.project.create({ + data: { + name: `e2e-pat-project-${suffix}`, + slug: `e2e-pat-proj-${suffix}`, + externalRef: `proj_${suffix}`, + organizationId: organization.id, + engine: "V2", + deletedAt: opts.projectDeleted ? new Date() : null, + }, + }); + + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + apiKey, + pkApiKey, + shortcode: suffix.slice(0, 4), + projectId: project.id, + organizationId: organization.id, + }, + }); + + const pat = await seedTestPAT(prisma, user.id); + + return { user, organization, project, environment, pat }; +} diff --git a/apps/webapp/test/helpers/seedTestWaitpoint.ts b/apps/webapp/test/helpers/seedTestWaitpoint.ts new file mode 100644 index 00000000000..f4794b2b6c1 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestWaitpoint.ts @@ -0,0 +1,29 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { customAlphabet } from "nanoid"; + +// Must match friendlyId.ts IdUtil alphabet so generated IDs are valid. +const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21); + +// Seeds a Waitpoint already in COMPLETED status so the waitpoints/:id/complete +// handler short-circuits with { success: true }. That keeps the "auth passes" +// assertion independent of run-engine workers (which are disabled in e2e). +export async function seedTestWaitpoint( + prisma: PrismaClient, + opts: { environmentId: string; projectId: string } +): Promise<{ id: string; friendlyId: string }> { + const internalId = idGenerator(); + const friendlyId = `waitpoint_${internalId}`; + await prisma.waitpoint.create({ + data: { + id: internalId, + friendlyId, + type: "MANUAL", + status: "COMPLETED", + idempotencyKey: internalId, + userProvidedIdempotencyKey: false, + environmentId: opts.environmentId, + projectId: opts.projectId, + }, + }); + return { id: internalId, friendlyId }; +} diff --git a/apps/webapp/test/helpers/sharedTestServer.ts b/apps/webapp/test/helpers/sharedTestServer.ts new file mode 100644 index 00000000000..35360fd221f --- /dev/null +++ b/apps/webapp/test/helpers/sharedTestServer.ts @@ -0,0 +1,53 @@ +// Per-worker access to the shared TestServer started by globalSetup. Each +// test file imports `getTestServer()` once at module top-level; the returned +// value is a singleton within that worker process. +// +// `webapp.fetch(path)` prepends the shared baseUrl. The PrismaClient is +// constructed lazily and disconnected on test-suite end via afterAll in the +// importing file (or left to the worker shutting down). + +import { PrismaClient } from "@trigger.dev/database"; +import { afterAll, inject } from "vitest"; + +interface SharedWebapp { + baseUrl: string; + fetch(path: string, init?: RequestInit): Promise; +} + +interface SharedTestServer { + webapp: SharedWebapp; + prisma: PrismaClient; +} + +let cached: SharedTestServer | undefined; + +export function getTestServer(): SharedTestServer { + if (cached) return cached; + + const baseUrl = inject("baseUrl"); + const databaseUrl = inject("databaseUrl"); + + if (!baseUrl || !databaseUrl) { + throw new Error( + "globalSetup didn't provide baseUrl/databaseUrl β€” run via vitest.e2e.full.config.ts" + ); + } + + const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }); + + cached = { + webapp: { + baseUrl, + fetch: (path, init) => fetch(`${baseUrl}${path}`, init), + }, + prisma, + }; + + // Disconnect the PrismaClient when the worker is done. globalSetup's + // teardown stops the container; this just releases the per-worker pool. + afterAll(async () => { + await prisma.$disconnect().catch(() => {}); + }); + + return cached; +} diff --git a/apps/webapp/test/setup/global-e2e-full-setup.ts b/apps/webapp/test/setup/global-e2e-full-setup.ts new file mode 100644 index 00000000000..31a9c15781f --- /dev/null +++ b/apps/webapp/test/setup/global-e2e-full-setup.ts @@ -0,0 +1,28 @@ +// vitest globalSetup β€” runs once for the whole *.e2e.full.test.ts suite. +// Boots one Postgres + Redis + webapp; tests connect to it via the +// `baseUrl` / `databaseUrl` values provided to test workers below. +// +// Each test file recreates its own PrismaClient connected to the shared DB +// (PrismaClient instances aren't serialisable across worker boundaries). + +import type { TestProject } from "vitest/node"; +import { startTestServer, type TestServer } from "@internal/testcontainers/webapp"; + +let server: TestServer | undefined; + +export default async function setup(project: TestProject) { + server = await startTestServer(); + project.provide("baseUrl", server.webapp.baseUrl); + project.provide("databaseUrl", server.databaseUrl); + + return async () => { + await server?.stop().catch(() => {}); + }; +} + +declare module "vitest" { + export interface ProvidedContext { + baseUrl: string; + databaseUrl: string; + } +} diff --git a/apps/webapp/test/utils/tracing.ts b/apps/webapp/test/utils/tracing.ts index 09500a6c354..91eac7b81cf 100644 --- a/apps/webapp/test/utils/tracing.ts +++ b/apps/webapp/test/utils/tracing.ts @@ -1,6 +1,5 @@ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; -import { trace } from "@opentelemetry/api"; import { MeterProvider, InMemoryMetricExporter, @@ -9,15 +8,24 @@ import { } from "@opentelemetry/sdk-metrics"; export function createInMemoryTracing() { - // Initialize the tracer provider and exporter + // Initialize the tracer provider and exporter β€” but do NOT call + // `provider.register()`. Calling register() sets the OTel global APIs + // (trace/context/propagation), and webapp's `~/v3/tracer.server.ts` + // also calls register() via its singleton. Webapp's `vitest.config.ts` + // uses `pool: "forks"` with `--no-file-parallelism`, so all test + // files in a shard share one process β€” globals set by the first test + // to load tracer.server.ts conflict with subsequent createInMemoryTracing + // calls, throwing "Attempted duplicate registration of API: trace". + // + // The tracer returned from `provider.getTracer(...)` is scoped to + // this provider, so the InMemorySpanExporter still receives the + // spans the consuming test creates β€” no global registration needed. const exporter = new InMemorySpanExporter(); const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)], }); - provider.register(); - // Retrieve the tracer - const tracer = trace.getTracer("test-tracer"); + const tracer = provider.getTracer("test-tracer"); return { exporter, diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts index 28f4056c463..91742c6ca76 100644 --- a/apps/webapp/test/validateGitBranchName.test.ts +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; +import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; describe("isValidGitBranchName", () => { it("returns true for a valid branch name", async () => { diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 2e51eb3f17d..66f697706a5 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -4,7 +4,10 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ test: { include: ["test/**/*.test.ts"], - exclude: ["test/**/*.e2e.test.ts"], + // *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts. + // *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts + // (needs a globalSetup-spawned webapp + Postgres container). + exclude: ["test/**/*.e2e.test.ts", "test/**/*.e2e.full.test.ts"], globals: true, pool: "forks", }, diff --git a/apps/webapp/vitest.e2e.full.config.ts b/apps/webapp/vitest.e2e.full.config.ts new file mode 100644 index 00000000000..47a4b0a8084 --- /dev/null +++ b/apps/webapp/vitest.e2e.full.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// Comprehensive auth e2e suite β€” see TRI-8731. Boots a single +// webapp + Postgres + Redis container in globalSetup and rapid-fires +// tests against it across multiple test files. Distinct from the smoke +// suite (vitest.e2e.config.ts) which uses per-file beforeAll setup and +// runs in default CI on every PR. +export default defineConfig({ + test: { + include: ["test/**/*.e2e.full.test.ts"], + globalSetup: ["./test/setup/global-e2e-full-setup.ts"], + globals: true, + pool: "forks", + testTimeout: 60_000, + hookTimeout: 180_000, + }, + // @ts-ignore + plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })], +}); diff --git a/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql b/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql new file mode 100644 index 00000000000..d7cdc1a0c0b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql @@ -0,0 +1,5 @@ +-- TRI-8892: optional RBAC role assignment carried on the invite. When +-- set, the accept-invite flow calls the loaded RBAC plugin's +-- setUserRole(rbacRoleId) after the OrgMember insert; otherwise the +-- runtime fallback derives the role from the legacy `role` column. +ALTER TABLE "OrgMemberInvite" ADD COLUMN IF NOT EXISTS "rbacRoleId" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index dcce2727683..f588bdfc453 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -289,6 +289,16 @@ model OrgMemberInvite { email String role OrgMemberRole @default(MEMBER) + /// Optional RBAC role to assign on invite acceptance. When set, the + /// accept-invite flow calls the loaded RBAC plugin's setUserRole with + /// this id after creating the OrgMember. Null = legacy behaviour, the + /// runtime fallback derives the role from `role` above. + /// + /// Plain text (not an FK) β€” the RBAC plugin's RbacRole table lives on + /// a separate schema (Drizzle, not Prisma) so we can't model the FK + /// here. Validation happens at write time (action) and read time + /// (acceptInvite). + rbacRoleId String? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String diff --git a/internal-packages/rbac/package.json b/internal-packages/rbac/package.json new file mode 100644 index 00000000000..d04089e4ff7 --- /dev/null +++ b/internal-packages/rbac/package.json @@ -0,0 +1,24 @@ +{ + "name": "@trigger.dev/rbac", + "private": true, + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "dependencies": { + "@trigger.dev/core": "workspace:*", + "@trigger.dev/plugins": "workspace:*" + }, + "devDependencies": { + "@trigger.dev/database": "workspace:*", + "@types/node": "^20.14.14", + "rimraf": "6.0.1" + }, + "scripts": { + "clean": "rimraf dist", + "typecheck": "tsc --noEmit", + "build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration", + "dev": "tsc --noEmit false --outDir dist --declaration --watch", + "test": "vitest run", + "test:watch": "vitest" + } +} diff --git a/internal-packages/rbac/src/ability.test.ts b/internal-packages/rbac/src/ability.test.ts new file mode 100644 index 00000000000..283a6341c52 --- /dev/null +++ b/internal-packages/rbac/src/ability.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { permissiveAbility, superAbility, denyAbility, buildFallbackAbility, buildJwtAbility } from "./ability.js"; + +describe("permissiveAbility", () => { + it("allows any action on any resource type", () => { + expect(permissiveAbility.can("read", { type: "run" })).toBe(true); + expect(permissiveAbility.can("write", { type: "deployment" })).toBe(true); + expect(permissiveAbility.can("delete", { type: "task" })).toBe(true); + }); + + it("allows actions on specific resource instances", () => { + expect(permissiveAbility.can("read", { type: "run", id: "run_abc123" })).toBe(true); + }); + + it("does not grant super-user access", () => { + expect(permissiveAbility.canSuper()).toBe(false); + }); +}); + +describe("superAbility", () => { + it("allows any action on any resource", () => { + expect(superAbility.can("read", { type: "run" })).toBe(true); + expect(superAbility.can("write", { type: "deployment" })).toBe(true); + }); + + it("grants super-user access", () => { + expect(superAbility.canSuper()).toBe(true); + }); +}); + +describe("denyAbility", () => { + it("denies all actions", () => { + expect(denyAbility.can("read", { type: "run" })).toBe(false); + expect(denyAbility.can("write", { type: "deployment" })).toBe(false); + }); + + it("does not grant super-user access", () => { + expect(denyAbility.canSuper()).toBe(false); + }); +}); + +describe("buildJwtAbility", () => { + it("allows action matching a general scope", () => { + const ability = buildJwtAbility(["read:runs"]); + expect(ability.can("read", { type: "runs" })).toBe(true); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true); + }); + + it("allows only the specific ID for a scoped permission", () => { + const ability = buildJwtAbility(["read:runs:run_abc"]); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true); + expect(ability.can("read", { type: "runs", id: "run_xyz" })).toBe(false); + expect(ability.can("read", { type: "runs" })).toBe(false); + }); + + it("allows any read with read:all scope", () => { + const ability = buildJwtAbility(["read:all"]); + expect(ability.can("read", { type: "runs" })).toBe(true); + expect(ability.can("read", { type: "tasks" })).toBe(true); + expect(ability.can("write", { type: "runs" })).toBe(false); + }); + + it("allows everything with admin scope", () => { + const ability = buildJwtAbility(["admin"]); + expect(ability.can("read", { type: "runs" })).toBe(true); + expect(ability.can("write", { type: "deployments" })).toBe(true); + }); + + // Pre-RBAC, the legacy checkAuthorization string-matched superScopes; + // a scope `admin:sessions` only granted access to routes that + // explicitly listed it. After the JWT-ability split we must not let + // `admin:` act as a universal wildcard β€” it should grant + // only the `admin` action against resources of that type. + it("admin: is not a universal wildcard", () => { + const ability = buildJwtAbility(["admin:sessions"]); + expect(ability.can("read", { type: "runs" })).toBe(false); + expect(ability.can("write", { type: "tasks" })).toBe(false); + expect(ability.can("admin", { type: "runs" })).toBe(false); + // But it does grant the admin action on its own type. + expect(ability.can("admin", { type: "sessions" })).toBe(true); + expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true); + }); + + it("admin:: grants admin action only on that exact resource", () => { + const ability = buildJwtAbility(["admin:sessions:ses_abc"]); + expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true); + expect(ability.can("admin", { type: "sessions", id: "ses_xyz" })).toBe(false); + expect(ability.can("admin", { type: "runs" })).toBe(false); + expect(ability.can("read", { type: "sessions", id: "ses_abc" })).toBe(false); + }); + + it("never grants canSuper", () => { + expect(buildJwtAbility(["admin"]).canSuper()).toBe(false); + expect(buildJwtAbility(["read:all"]).canSuper()).toBe(false); + expect(buildJwtAbility([]).canSuper()).toBe(false); + }); + + it("denies everything for empty scopes", () => { + const ability = buildJwtAbility([]); + expect(ability.can("read", { type: "runs" })).toBe(false); + }); + + it("denies wrong action with general resource scope", () => { + const ability = buildJwtAbility(["read:runs"]); + expect(ability.can("write", { type: "runs" })).toBe(false); + }); +}); + +describe("buildJwtAbility β€” array resources", () => { + it("authorizes when any resource in the array passes a scope check", () => { + const ability = buildJwtAbility(["read:batch:batch_abc"]); + const resources = [ + { type: "runs", id: "run_xyz" }, + { type: "batch", id: "batch_abc" }, + { type: "tasks", id: "task_other" }, + ]; + expect(ability.can("read", resources)).toBe(true); + }); + + it("rejects when no resource in the array passes a scope check", () => { + const ability = buildJwtAbility(["read:batch:batch_abc"]); + const resources = [ + { type: "runs", id: "run_xyz" }, + { type: "batch", id: "batch_other" }, + { type: "tasks", id: "task_other" }, + ]; + expect(ability.can("read", resources)).toBe(false); + }); + + it("empty array never authorizes", () => { + const ability = buildJwtAbility(["read:all"]); + expect(ability.can("read", [])).toBe(false); + }); + + it("authorizes a single resource via the non-array form (backwards compatible)", () => { + const ability = buildJwtAbility(["read:runs:run_abc"]); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true); + }); +}); + +describe("buildFallbackAbility", () => { + it("returns permissiveAbility for non-admin users", () => { + const ability = buildFallbackAbility(false); + expect(ability.can("read", { type: "run" })).toBe(true); + expect(ability.canSuper()).toBe(false); + }); + + it("returns superAbility for admin users", () => { + const ability = buildFallbackAbility(true); + expect(ability.can("read", { type: "run" })).toBe(true); + expect(ability.canSuper()).toBe(true); + }); +}); diff --git a/internal-packages/rbac/src/ability.ts b/internal-packages/rbac/src/ability.ts new file mode 100644 index 00000000000..c25a5f49bf6 --- /dev/null +++ b/internal-packages/rbac/src/ability.ts @@ -0,0 +1,55 @@ +import type { RbacAbility, RbacResource } from "@trigger.dev/plugins"; + +/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */ +export const permissiveAbility: RbacAbility = { + can: () => true, + canSuper: () => false, +}; + +/** Platform admin (user.admin = true): can do everything including super-user actions. */ +export const superAbility: RbacAbility = { + can: () => true, + canSuper: () => true, +}; + +/** Deprecated PUBLIC tokens and unauthenticated subjects: denied everything. */ +export const denyAbility: RbacAbility = { + can: () => false, + canSuper: () => false, +}; + +export function buildFallbackAbility(isAdmin: boolean): RbacAbility { + return isAdmin ? superAbility : permissiveAbility; +} + +/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */ +export function buildJwtAbility(scopes: string[]): RbacAbility { + const matches = (action: string, r: RbacResource): boolean => + scopes.some((scope) => { + const [scopeAction, scopeType, scopeId] = scope.split(":"); + // Bare `admin` is the universal wildcard. `admin:` is *not* β€” + // it falls through to normal matching as action="admin" against + // resources of that type. Pre-RBAC, the legacy checkAuthorization + // string-matched superScopes; `admin:sessions` only granted access + // to routes that explicitly listed it. Treating `admin:` + // as universal here would silently broaden any such tokens. + if (scopeAction === "admin" && !scopeType) return true; + if (scopeAction !== action && scopeAction !== "*") return false; + if (scopeType === "all") return true; + if (scopeType !== r.type) return false; + if (!scopeId) return true; + return scopeId === r.id; + }); + return { + can(action: string, resource: RbacResource | RbacResource[]): boolean { + // Array form means "any element passes β†’ authorized", matching the + // legacy multi-key checkAuthorization semantic. + return Array.isArray(resource) + ? resource.some((r) => matches(action, r)) + : matches(action, resource); + }, + canSuper(): boolean { + return false; + }, + }; +} diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts new file mode 100644 index 00000000000..31b77742bec --- /dev/null +++ b/internal-packages/rbac/src/fallback.ts @@ -0,0 +1,418 @@ +import type { + Permission, + Role, + RbacEnvironment, + RbacUser, + RbacSubject, + RbacResource, + BearerAuthResult, + PatAuthResult, + SessionAuthResult, + RoleAssignmentResult, + RoleBaseAccessController, + RoleMutationResult, +} from "@trigger.dev/plugins"; +import { createHash } from "node:crypto"; +import type { PrismaClient } from "@trigger.dev/database"; +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js"; + +export type FallbackPrismaClients = { + // Used for writes (setUserRole, mutateRole, etc.) and any reads that + // can't tolerate replica lag (currently none on this controller, but + // kept for symmetry with the rest of the webapp). + primary: PrismaClient; + // Used for read-only auth-path queries: bearer-token env lookup, + // PAT lookup, session user lookup. Spreads the high-frequency auth + // load away from the primary, matching what `findEnvironmentByApiKey` + // / `findEnvironmentById` did before this PR. + replica: PrismaClient; +}; + +// Backwards-compat: a single PrismaClient is treated as both primary +// and replica. Callers that care about replica isolation pass the +// explicit FallbackPrismaClients shape. +type PrismaInput = PrismaClient | FallbackPrismaClients; + +function resolvePrismaClients(input: PrismaInput): FallbackPrismaClients { + return "primary" in input ? input : { primary: input, replica: input }; +} + +export class RoleBaseAccessFallback { + private readonly clients: FallbackPrismaClients; + + constructor(prisma: PrismaInput) { + this.clients = resolvePrismaClients(prisma); + } + + create(): RoleBaseAccessFallbackController { + return new RoleBaseAccessFallbackController(this.clients); + } +} + +class RoleBaseAccessFallbackController implements RoleBaseAccessController { + private readonly prisma: PrismaClient; // alias for primary β€” used by writes + private readonly replica: PrismaClient; + + constructor(clients: FallbackPrismaClients) { + this.prisma = clients.primary; + this.replica = clients.replica; + } + + async authenticateBearer( + request: Request, + options?: { allowJWT?: boolean } + ): Promise { + const rawToken = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim(); + if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" }; + + if (options?.allowJWT && isPublicJWT(rawToken)) { + const envId = extractJWTSub(rawToken); + if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" }; + + // Match the include shape of the slim AuthenticatedEnvironment so + // the bridge can use the returned env without a follow-up fetch. + const env = await this.replica.runtimeEnvironment.findFirst({ + where: { id: envId }, + include: { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { select: { id: true, apiKey: true } }, + }, + }); + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid Public Access Token" }; + } + + const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey; + const result = await validateJWT(rawToken, signingKey); + if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" }; + + const scopes = Array.isArray(result.payload.scopes) + ? (result.payload.scopes as string[]) + : []; + const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined; + const oneTimeUse = result.payload.otu === true; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject: { + type: "publicJWT", + environmentId: env.id, + organizationId: env.organizationId, + projectId: env.projectId, + }, + ability: buildJwtAbility(scopes), + jwt: { realtime, oneTimeUse }, + }; + } + + // PREVIEW envs are parents β€” operating "on a branch" means routing + // to a child env keyed by branchName. The customer authenticates + // with the parent's apiKey + an `x-trigger-branch` header. Mirror + // findEnvironmentByApiKey: include the matching child env so the + // pivot below can adopt its identity. + const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch")); + // Match the include shape of the slim AuthenticatedEnvironment so + // the apiBuilder bridge can use the returned env directly without a + // follow-up findEnvironmentById call. + const include = { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { select: { id: true, apiKey: true } }, + childEnvironments: branchName + ? { where: { branchName, archivedAt: null } } + : undefined, + } as const; + let env = await this.replica.runtimeEnvironment.findFirst({ + where: { apiKey: rawToken }, + include, + }); + + // Revoked API key grace window β€” mirrors `findEnvironmentByApiKey` + // in apps/webapp/app/models/runtimeEnvironment.server.ts. Recently + // rotated keys keep working until their `expiresAt`; without this + // branch a customer who rotates an env API key gets immediate 401s + // on the new auth path. The PR's e2e suite covers this in + // auth-cross-cutting.e2e.full.test.ts ("revoked key within grace"). + if (!env) { + const revoked = await this.replica.revokedApiKey.findFirst({ + where: { apiKey: rawToken, expiresAt: { gt: new Date() } }, + include: { runtimeEnvironment: { include } }, + }); + env = revoked?.runtimeEnvironment ?? null; + } + + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid API key" }; + } + + // PREVIEW env requires a branch header; pivot to the child env so + // downstream code operates on the branch (its own id, but the + // parent's apiKey/orgMember/organization/project β€” exactly what + // findEnvironmentByApiKey does for the legacy auth path). + if (env.type === "PREVIEW") { + if (!branchName) { + return { + ok: false, + status: 401, + error: "x-trigger-branch header required for preview env", + }; + } + const child = env.childEnvironments?.[0]; + if (!child) { + return { ok: false, status: 401, error: "No matching branch env" }; + } + // Pivot to the child env: child's id/type/branchName, parent's + // apiKey/orgMember/organization/project. parentEnvironment is set + // explicitly here so the slim shape stays internally consistent. + env = { + ...child, + apiKey: env.apiKey, + orgMember: env.orgMember, + organization: env.organization, + project: env.project, + parentEnvironment: { id: env.id, apiKey: env.apiKey }, + childEnvironments: [], + }; + } + + const subject: RbacSubject = { + type: "user", + userId: env.orgMember?.userId ?? "", + organizationId: env.organizationId, + projectId: env.projectId, + }; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject, + ability: permissiveAbility, + }; + } + + async authenticateSession( + _request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string } + ): Promise { + if (!context.userId) return { ok: false, reason: "unauthenticated" }; + + const user = await this.replica.user.findFirst({ where: { id: context.userId } }); + if (!user) return { ok: false, reason: "unauthenticated" }; + + const subject: RbacSubject = { + type: "user", + userId: user.id, + organizationId: context.organizationId ?? "", + projectId: context.projectId, + }; + + return { + ok: true, + user: toRbacUser(user), + subject, + ability: buildFallbackAbility(user.admin), + }; + } + + async authenticateAuthorizeBearer( + request: Request, + check: { action: string; resource: RbacResource | RbacResource[] }, + options?: { allowJWT?: boolean } + ): Promise { + const auth = await this.authenticateBearer(request, options); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false, status: 403, error: "Unauthorized" }; + } + return auth; + } + + async authenticateAuthorizeSession( + request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string }, + check: { action: string; resource: RbacResource | RbacResource[] } + ): Promise { + const auth = await this.authenticateSession(request, context); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false, reason: "unauthorized" }; + } + return auth; + } + + async authenticatePat( + request: Request, + context: { organizationId?: string; projectId?: string } + ): Promise { + const rawToken = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (!rawToken || !rawToken.startsWith("tr_pat_")) { + return { ok: false, status: 401, error: "Invalid or Missing PAT" }; + } + + const hashedToken = createHash("sha256").update(rawToken).digest("hex"); + const pat = await this.replica.personalAccessToken.findFirst({ + where: { hashedToken, revokedAt: null }, + select: { id: true, userId: true }, + }); + if (!pat) { + return { ok: false, status: 401, error: "Invalid PAT" }; + } + + return { + ok: true, + tokenId: pat.id, + userId: pat.userId, + subject: { + type: "personalAccessToken", + tokenId: pat.id, + organizationId: context.organizationId ?? "", + projectId: context.projectId, + }, + // No plugin β†’ no role lookup. PATs in the OSS world are pure + // user-identity tokens; the route's own authorization block (or + // the absence of one) decides what they can do, same as it did + // before this method existed. + ability: permissiveAbility, + }; + } + + async systemRoles(_organizationId: string) { + // No plugin installed β†’ no seeded roles. Callers handle null by + // hiding role-picker UI / skipping role assignment writes. + return null; + } + + async allPermissions(): Promise { + return []; + } + + async allRoles(): Promise { + return []; + } + + // Permissive β€” the default fallback applies no gating. The Teams + // page UI uses this to decide which role options to render as + // disabled; with no plugin installed allRoles() returns [] anyway, + // so the practical effect is "no roles to gate". + async getAssignableRoleIds(): Promise { + return []; + } + + async createRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async updateRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async deleteRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async getUserRole(): Promise { + return null; + } + + async getUserRoles(userIds: string[]): Promise> { + return new Map(userIds.map((id) => [id, null])); + } + + async setUserRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async removeUserRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async getTokenRole(): Promise { + return null; + } + + async setTokenRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async removeTokenRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } +} + +function isPublicJWT(token: string): boolean { + const parts = token.split("."); + if (parts.length !== 3) return false; + try { + const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + return payload !== null && typeof payload === "object" && payload.pub === true; + } catch { + return false; + } +} + +function extractJWTSub(token: string): string | undefined { + const parts = token.split("."); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + return payload !== null && typeof payload === "object" && typeof payload.sub === "string" + ? payload.sub + : undefined; + } catch { + return undefined; + } +} + +// Coerce a Prisma RuntimeEnvironment payload (with project/organization/ +// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment +// the auth contract carries. The slim type accepts both `number` and +// Decimal-like for `concurrencyLimitBurstFactor`, but explicit coercion +// here keeps the value a plain number across the auth boundary so +// downstream consumers don't have to narrow before doing arithmetic. +function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment { + const burst = env.concurrencyLimitBurstFactor; + return { + ...env, + concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(), + }; +} + +function toRbacUser(user: { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + confirmedBasicDetails: boolean; +}): RbacUser { + return { + id: user.id, + email: user.email, + name: user.name, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + admin: user.admin, + confirmedBasicDetails: user.confirmedBasicDetails, + isImpersonating: false, + }; +} diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts new file mode 100644 index 00000000000..65dbdf22859 --- /dev/null +++ b/internal-packages/rbac/src/index.ts @@ -0,0 +1,254 @@ +import type { + Permission, + RbacAbility, + Role, + RbacResource, + RoleAssignmentResult, + RoleBaseAccessController, + RoleBasedAccessControlPlugin, + RoleMutationResult, +} from "@trigger.dev/plugins"; +import type { PrismaClient } from "@trigger.dev/database"; +import { RoleBaseAccessFallback } from "./fallback.js"; +export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; + +// Either a single PrismaClient (used for both writes and reads β€” fine +// for callers that don't have a separate replica), or `{primary, replica}` +// where reads on the auth hot path go to the replica. The fallback +// reads on every request, so callers with a replica should pass both. +export type RbacPrismaInput = PrismaClient | { primary: PrismaClient; replica: PrismaClient }; + +export type RbacCreateOptions = { + // When true, skip loading the plugin, useful for tests + forceFallback?: boolean; +}; + +// Route actions that historically authorised via the legacy checkAuthorization's +// superScopes escape hatch β€” e.g. a JWT with scope "write:tasks" was accepted by +// a route with action: "trigger" because "write:tasks" was listed in the route's +// superScopes array. The new ability model matches scope-action strictly, so we +// restore the prior semantic here: when the underlying ability denies for action +// X, retry with each aliased action. +const ACTION_ALIASES: Record = { + trigger: ["write"], + batchTrigger: ["write"], + update: ["write"], +}; + +export function withActionAliases(underlying: RbacAbility): RbacAbility { + return { + can(action: string, resource: RbacResource | RbacResource[]): boolean { + if (underlying.can(action, resource)) return true; + const aliases = ACTION_ALIASES[action] ?? []; + return aliases.some((a) => underlying.can(a, resource)); + }, + canSuper: () => underlying.canSuper(), + }; +} + +// Loads the plugin lazily; falls back to the fallback implementation if not installed. +// Synchronous create() avoids top-level await (not supported in the webapp's CJS build). +class LazyController implements RoleBaseAccessController { + private readonly _init: Promise; + + constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) { + this._init = this.load(prisma, options); + } + + private async load( + prisma: RbacPrismaInput, + options?: RbacCreateOptions + ): Promise { + if (options?.forceFallback) { + return new RoleBaseAccessFallback(prisma).create(); + } + const moduleName = "@triggerdotdev/plugins/rbac"; + try { + const module = await import(moduleName); + const plugin: RoleBasedAccessControlPlugin = module.default; + console.log("RBAC: using plugin implementation"); + return plugin.create(); + } catch (err) { + // The dynamic import either succeeded or failed for one of two + // distinct reasons. Distinguishing them is critical for debugging + // β€” silently swallowing the error here is what produced "why is + // the fallback being used?" mysteries before. + // + // 1. The plugin itself is absent (no install) β€” expected. + // Logged at info level only when RBAC_LOG_FALLBACK=1 so + // production logs stay quiet. + // 2. Anything else (transitive dep missing, init error, syntax + // error in the plugin's dist, etc.) β€” a real bug. Always + // logged loudly so it surfaces in CI / production logs. + // + // Node throws ERR_MODULE_NOT_FOUND for both cases β€” the *plugin* + // module being absent and a *transitive* dep of the plugin + // being absent. Disambiguate by checking whether the missing + // specifier in the error message is the plugin's own moduleName. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + const message = err instanceof Error ? err.message : String(err); + const isModuleNotFound = + code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; + const isPluginItselfMissing = + isModuleNotFound && message.includes(moduleName); + + if (!isPluginItselfMissing) { + // Either the error wasn't a missing-module error at all, or the + // plugin was found but a transitive dep failed to resolve. + // Either way: a real problem worth surfacing. + console.error( + "RBAC: plugin found but failed to load; falling back to default implementation", + err + ); + } else if (process.env.RBAC_LOG_FALLBACK === "1") { + console.log( + "RBAC: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback" + ); + } + return new RoleBaseAccessFallback(prisma).create(); + } + } + + private async c(): Promise { + return this._init; + } + + async authenticateBearer(...args: Parameters) { + const result = await (await this.c()).authenticateBearer(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + + async authenticateSession(...args: Parameters) { + const result = await (await this.c()).authenticateSession(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + + // Don't delegate to the underlying Authorize variants β€” that would run the + // inline ability check against the unwrapped ability. Use our wrapped + // authenticate* and do the ability check here instead. + async authenticateAuthorizeBearer( + request: Parameters[0], + check: Parameters[1], + options?: Parameters[2] + ) { + const auth = await this.authenticateBearer(request, options); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false as const, status: 403 as const, error: "Unauthorized" }; + } + return auth; + } + + async authenticateAuthorizeSession( + request: Parameters[0], + context: Parameters[1], + check: Parameters[2] + ) { + const auth = await this.authenticateSession(request, context); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false as const, reason: "unauthorized" as const }; + } + return auth; + } + + async authenticatePat(...args: Parameters) { + const result = await (await this.c()).authenticatePat(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + + async systemRoles(...args: Parameters) { + return (await this.c()).systemRoles(...args); + } + + async allPermissions( + ...args: Parameters + ): Promise { + return (await this.c()).allPermissions(...args); + } + + async allRoles(...args: Parameters): Promise { + return (await this.c()).allRoles(...args); + } + + async getAssignableRoleIds( + ...args: Parameters + ): Promise { + return (await this.c()).getAssignableRoleIds(...args); + } + + async createRole( + ...args: Parameters + ): Promise { + return (await this.c()).createRole(...args); + } + + async updateRole( + ...args: Parameters + ): Promise { + return (await this.c()).updateRole(...args); + } + + async deleteRole( + ...args: Parameters + ): Promise { + return (await this.c()).deleteRole(...args); + } + + async getUserRole( + ...args: Parameters + ): Promise { + return (await this.c()).getUserRole(...args); + } + + async getUserRoles( + ...args: Parameters + ): Promise> { + return (await this.c()).getUserRoles(...args); + } + + async setUserRole( + ...args: Parameters + ): Promise { + return (await this.c()).setUserRole(...args); + } + + async removeUserRole( + ...args: Parameters + ): Promise { + return (await this.c()).removeUserRole(...args); + } + + async getTokenRole( + ...args: Parameters + ): Promise { + return (await this.c()).getTokenRole(...args); + } + + async setTokenRole( + ...args: Parameters + ): Promise { + return (await this.c()).setTokenRole(...args); + } + + async removeTokenRole( + ...args: Parameters + ): Promise { + return (await this.c()).removeTokenRole(...args); + } +} + +class RoleBaseAccess { + // Synchronous β€” returns a lazy controller that resolves any installed + // plugin on first call. + create( + prisma: RbacPrismaInput, + options?: RbacCreateOptions + ): RoleBaseAccessController { + return new LazyController(prisma, options); + } +} + +const loader = new RoleBaseAccess(); + +export default loader; diff --git a/internal-packages/rbac/src/loader.test.ts b/internal-packages/rbac/src/loader.test.ts new file mode 100644 index 00000000000..151bdcf9683 --- /dev/null +++ b/internal-packages/rbac/src/loader.test.ts @@ -0,0 +1,69 @@ +import type { RbacAbility } from "@trigger.dev/plugins"; +import { describe, expect, it } from "vitest"; +import { buildJwtAbility } from "./ability.js"; +import { withActionAliases } from "./index.js"; + +describe("withActionAliases", () => { + it("direct action match passes through unchanged", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks"])); + expect(ability.can("write", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("trigger action is satisfied by a write:tasks scope (alias retry)", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("batchTrigger action is satisfied by a write:tasks scope (alias retry)", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks"])); + expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("update action is satisfied by a write:prompts scope (alias retry)", () => { + const ability = withActionAliases(buildJwtAbility(["write:prompts"])); + expect(ability.can("update", { type: "prompts", id: "p_x" })).toBe(true); + }); + + it("id-scoped write scope satisfies the aliased action on matching id", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("id-scoped write scope denies the aliased action on a different id", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"])); + expect(ability.can("trigger", { type: "tasks", id: "task_other" })).toBe(false); + }); + + it("read scope does not satisfy a trigger action (aliases are write-only)", () => { + const ability = withActionAliases(buildJwtAbility(["read:tasks"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(false); + }); + + it("non-aliased custom action only matches its direct action scope", () => { + const ability = withActionAliases(buildJwtAbility(["read:runs"])); + expect(ability.can("someOtherAction", { type: "runs", id: "run_x" })).toBe(false); + }); + + it("admin scope continues to grant everything regardless of aliases", () => { + const ability = withActionAliases(buildJwtAbility(["admin"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true); + expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true); + expect(ability.can("anything", { type: "whatever", id: "x" })).toBe(true); + }); + + it("array resource form: alias retry applies when any element passes", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"])); + const resources = [ + { type: "tasks", id: "task_other" }, + { type: "tasks", id: "task_x" }, + ]; + expect(ability.can("trigger", resources)).toBe(true); + }); + + it("canSuper is delegated unchanged", () => { + const allowSuper: RbacAbility = { can: () => false, canSuper: () => true }; + const denySuper: RbacAbility = { can: () => false, canSuper: () => false }; + expect(withActionAliases(allowSuper).canSuper()).toBe(true); + expect(withActionAliases(denySuper).canSuper()).toBe(false); + }); +}); diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json new file mode 100644 index 00000000000..8da0857b403 --- /dev/null +++ b/internal-packages/rbac/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "customConditions": ["@triggerdotdev/source"] + }, + "exclude": ["node_modules"] +} diff --git a/internal-packages/rbac/vitest.config.ts b/internal-packages/rbac/vitest.config.ts new file mode 100644 index 00000000000..e07f05e842b --- /dev/null +++ b/internal-packages/rbac/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + globals: true, + isolate: true, + testTimeout: 10_000, + }, +}); diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index fdcf1a6f89a..06c80f67f2c 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -63,6 +63,7 @@ import { import { SystemResources } from "./systems.js"; import { WaitpointSystem } from "./waitpointSystem.js"; import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic"; +import type { AuthenticatedEnvironment } from "../../shared/index.js"; export type RunAttemptSystemOptions = { resources: SystemResources; @@ -1055,7 +1056,16 @@ export class RunAttemptSystem { organization: { id: run.runtimeEnvironment.organizationId, }, - environment: run.runtimeEnvironment, + // The Prisma payload structurally satisfies the slim + // AuthenticatedEnvironment except for `concurrencyLimitBurstFactor` + // (Decimal vs number). Coerce that one field; cast away + // the excess-property mismatch (the rest of Prisma's + // RuntimeEnvironment columns are extra, not missing). + environment: { + ...run.runtimeEnvironment, + concurrencyLimitBurstFactor: + run.runtimeEnvironment.concurrencyLimitBurstFactor.toNumber(), + } as unknown as AuthenticatedEnvironment, retryAt, }); diff --git a/internal-packages/run-engine/src/shared/index.ts b/internal-packages/run-engine/src/shared/index.ts index e2b36e464e9..30006b20957 100644 --- a/internal-packages/run-engine/src/shared/index.ts +++ b/internal-packages/run-engine/src/shared/index.ts @@ -1,21 +1,24 @@ import type { Attributes } from "@internal/tracing"; -import type { Prisma } from "@trigger.dev/database"; -export type AuthenticatedEnvironment = Prisma.RuntimeEnvironmentGetPayload<{ - include: { project: true; organization: true; orgMember: true }; -}>; +// Slim, structural shape carried across the auth boundary. Defined in +// @trigger.dev/core so it's importable from internal packages and the +// RBAC plugin contract without depending on @trigger.dev/database. +export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +// Run-engine internal type β€” what enqueue/dequeue/concurrency code +// actually needs from an env. Independent of `AuthenticatedEnvironment` +// (the auth-boundary slim type) because internals receive Prisma +// payloads where `concurrencyLimitBurstFactor` is `Decimal`. Accept +// both number and a Decimal-like duck type so callers don't need to +// coerce at every site. export type MinimalAuthenticatedEnvironment = { - id: AuthenticatedEnvironment["id"]; + id: string; type: AuthenticatedEnvironment["type"]; - maximumConcurrencyLimit: AuthenticatedEnvironment["maximumConcurrencyLimit"]; - concurrencyLimitBurstFactor: AuthenticatedEnvironment["concurrencyLimitBurstFactor"]; - project: { - id: AuthenticatedEnvironment["project"]["id"]; - }; - organization: { - id: AuthenticatedEnvironment["organization"]["id"]; - }; + maximumConcurrencyLimit: number; + concurrencyLimitBurstFactor: number | { toNumber(): number }; + project: { id: string }; + organization: { id: string }; }; const SemanticEnvResources = { diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index b3f69f77d0a..eca9b06d388 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -7,7 +7,7 @@ import path from "path"; import { isDebug } from "std-env"; import { GenericContainer, StartedNetwork, StartedTestContainer, Wait } from "testcontainers"; import { x } from "tinyexec"; -import { expect, TaskContext } from "vitest"; +import type { TaskContext } from "vitest"; import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse"; import { MinIOContainer } from "./minio"; import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs"; @@ -186,8 +186,21 @@ export async function createMinIOContainer(network: StartedNetwork) { } export function assertNonNullable(value: T): asserts value is NonNullable { - expect(value).toBeDefined(); - expect(value).not.toBeNull(); + // Plain throw β€” *not* `vitest.expect`. Two reasons: + // 1. This module is imported by globalSetup files that run before any + // vitest worker exists, so `import { expect }` from "vitest" at + // top level can crash on init. + // 2. Lazy-loading via `require("vitest")` (the prior fix) collides + // with OTel auto-instrumentation: `@opentelemetry/instrumentation` + // hooks `require()` via `require-in-the-middle`, and vitest is + // ESM-only β€” the require() throws "Vitest cannot be imported in + // a CommonJS module using require()", failing every test that + // uses `assertNonNullable` after OTel's been touched. + // The plain throw still gives vitest a useful failure (the message is + // shown in the stack trace) without the instrumentation hazard. + if (value === null || value === undefined) { + throw new Error(`assertNonNullable: value was ${value === null ? "null" : "undefined"}`); + } } export async function withContainerSetup({ diff --git a/internal-packages/testcontainers/src/webapp.ts b/internal-packages/testcontainers/src/webapp.ts index 9530f4c38fb..108eb911971 100644 --- a/internal-packages/testcontainers/src/webapp.ts +++ b/internal-packages/testcontainers/src/webapp.ts @@ -37,13 +37,29 @@ export interface WebappInstance { fetch(path: string, init?: RequestInit): Promise; } +export interface StartWebappOptions { + /** + * When true (default), the spawned webapp runs with `RBAC_FORCE_FALLBACK=1` + * so the default fallback handles all auth checks. The comprehensive + * suite (`*.e2e.full.test.ts`) relies on this β€” it's pinned to the + * fallback so results don't depend on whether `@triggerdotdev/plugins/rbac` + * happens to be installed in the local node_modules. + * + * Set to false to spawn a webapp that loads any installed RBAC + * plugin instead, for testing the plugin path. + */ + forceRbacFallback?: boolean; +} + export async function startWebapp( databaseUrl: string, - redis: { host: string; port: number } + redis: { host: string; port: number }, + options: StartWebappOptions = {} ): Promise<{ instance: WebappInstance; stop: () => Promise; }> { + const forceRbacFallback = options.forceRbacFallback ?? true; const port = await findFreePort(); // Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable @@ -56,7 +72,12 @@ export async function startWebapp( cwd: WEBAPP_ROOT, env: { ...process.env, - NODE_ENV: "test", + // Match `pnpm run start` (production-mode boot). NODE_ENV=test + // surfaces a circular-init regression in the production bundle + // β€” see TRI-8731 β€” that production-mode dodges by initialising + // modules in a different order. Tests don't depend on test-mode + // semantics; they only need an isolated webapp + DB. + NODE_ENV: "production", DATABASE_URL: databaseUrl, DIRECT_URL: databaseUrl, PORT: String(port), @@ -81,6 +102,11 @@ export async function startWebapp( RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv) RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv) RUN_REPLICATION_ENABLED: "0", + // Force the RBAC loader to use the default fallback in e2e tests + // so auth behaviour is deterministic regardless of whether a + // plugin is installed in the local node_modules. Set to "0" / + // undefined to spawn a webapp that loads any installed plugin. + ...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}), NODE_PATH: nodePath, }, stdio: ["ignore", "pipe", "pipe"], @@ -147,15 +173,21 @@ export async function startWebapp( export interface TestServer { webapp: WebappInstance; prisma: PrismaClient; + // Postgres connection string. Useful when test workers run in separate + // processes and need to construct their own clients against the same DB. + databaseUrl: string; stop: () => Promise; } /** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */ -export async function startTestServer(): Promise { +export async function startTestServer( + options: StartWebappOptions = {} +): Promise { const network = await new Network().start(); // Track each resource as we acquire it so we can tear it down if a later step fails. let pgContainer: Awaited>["container"] | undefined; + let pgUrl: string | undefined; let redisContainer: Awaited>["container"] | undefined; let prisma: PrismaClient | undefined; let stopWebapp: (() => Promise) | undefined; @@ -164,13 +196,18 @@ export async function startTestServer(): Promise { try { const pg = await createPostgresContainer(network); pgContainer = pg.container; + pgUrl = pg.url; const { container: rc } = await createRedisContainer({ network }); redisContainer = rc; prisma = new PrismaClient({ datasources: { db: { url: pg.url } } }); await prisma.$connect(); // pre-warm pool; surface connection failures before tests start - const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() }); + const started = await startWebapp( + pg.url, + { host: rc.getHost(), port: rc.getPort() }, + options + ); webapp = started.instance; stopWebapp = started.stop; } catch (err) { @@ -190,5 +227,5 @@ export async function startTestServer(): Promise { await network.stop().catch((err) => console.error("network.stop failed:", err)); }; - return { webapp, prisma: prisma!, stop }; + return { webapp, prisma: prisma!, databaseUrl: pgUrl!, stop }; } diff --git a/packages/core/package.json b/packages/core/package.json index f58708dff92..32dd06d180d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,7 @@ "./v3/tracer": "./src/v3/tracer.ts", "./v3/build": "./src/v3/build/index.ts", "./v3/apps": "./src/v3/apps/index.ts", + "./v3/auth/environment": "./src/v3/auth/environment.ts", "./v3/jwt": "./src/v3/jwt.ts", "./v3/errors": "./src/v3/errors.ts", "./v3/logger-api": "./src/v3/logger-api.ts", @@ -37,6 +38,7 @@ "./v3/semanticInternalAttributes": "./src/v3/semanticInternalAttributes.ts", "./v3/utils/durations": "./src/v3/utils/durations.ts", "./v3/utils/flattenAttributes": "./src/v3/utils/flattenAttributes.ts", + "./v3/utils/gitBranch": "./src/v3/utils/gitBranch.ts", "./v3/utils/ioSerialization": "./src/v3/utils/ioSerialization.ts", "./v3/utils/omit": "./src/v3/utils/omit.ts", "./v3/utils/retries": "./src/v3/utils/retries.ts", @@ -96,12 +98,18 @@ "v3/semanticInternalAttributes": [ "dist/commonjs/v3/semanticInternalAttributes.d.ts" ], + "v3/auth/environment": [ + "dist/commonjs/v3/auth/environment.d.ts" + ], "v3/utils/durations": [ "dist/commonjs/v3/utils/durations.d.ts" ], "v3/utils/flattenAttributes": [ "dist/commonjs/v3/utils/flattenAttributes.d.ts" ], + "v3/utils/gitBranch": [ + "dist/commonjs/v3/utils/gitBranch.d.ts" + ], "v3/utils/ioSerialization": [ "dist/commonjs/v3/utils/ioSerialization.d.ts" ], @@ -325,6 +333,17 @@ "default": "./dist/commonjs/v3/apps/index.js" } }, + "./v3/auth/environment": { + "import": { + "@triggerdotdev/source": "./src/v3/auth/environment.ts", + "types": "./dist/esm/v3/auth/environment.d.ts", + "default": "./dist/esm/v3/auth/environment.js" + }, + "require": { + "types": "./dist/commonjs/v3/auth/environment.d.ts", + "default": "./dist/commonjs/v3/auth/environment.js" + } + }, "./v3/jwt": { "import": { "@triggerdotdev/source": "./src/v3/jwt.ts", @@ -402,6 +421,17 @@ "default": "./dist/commonjs/v3/utils/flattenAttributes.js" } }, + "./v3/utils/gitBranch": { + "import": { + "@triggerdotdev/source": "./src/v3/utils/gitBranch.ts", + "types": "./dist/esm/v3/utils/gitBranch.d.ts", + "default": "./dist/esm/v3/utils/gitBranch.js" + }, + "require": { + "types": "./dist/commonjs/v3/utils/gitBranch.d.ts", + "default": "./dist/commonjs/v3/utils/gitBranch.js" + } + }, "./v3/utils/ioSerialization": { "import": { "@triggerdotdev/source": "./src/v3/utils/ioSerialization.ts", diff --git a/packages/core/src/v3/auth/environment.ts b/packages/core/src/v3/auth/environment.ts new file mode 100644 index 00000000000..8918f191300 --- /dev/null +++ b/packages/core/src/v3/auth/environment.ts @@ -0,0 +1,108 @@ +// Slim shape of an authenticated runtime environment, structural and +// independent of @trigger.dev/database. Carried across the auth boundary +// (RBAC plugin contract β†’ host webapp) so plugins can return all the +// fields handlers consume without a follow-up DB lookup. +// +// This is hand-rolled rather than derived from `Prisma.RuntimeEnvironmentGetPayload` +// because the contract package (@trigger.dev/plugins) is published while +// @trigger.dev/database is private β€” and because callers of this type +// genuinely use only a fraction of the columns Prisma would expose. +// +// If a downstream consumer needs a field that's not here: +// - Used in the auth-cross-cutting hot path β†’ add it +// - Used in a service that already loads the env β†’ fetch it there instead +// +// `concurrencyLimitBurstFactor` is a `Decimal(4,2)` in Postgres β€” values +// are O(2.00) in practice; coerced to `number` here (lossless at this +// scale, avoids dragging in Prisma's Decimal class via type imports). + +// String-literal unions mirror the corresponding Prisma enums. Defining +// them here keeps the contract structural (no @trigger.dev/database +// import) while giving downstream consumers the same exact union they +// expect when this value is passed to a Prisma column. +export type RuntimeEnvironmentType = + | "PRODUCTION" + | "STAGING" + | "DEVELOPMENT" + | "PREVIEW"; + +export type RunEngineVersion = "V1" | "V2"; + +// Prisma's Decimal class. Accept it structurally so consumers (mostly +// the webapp's `runtimeEnvironment.server.ts` model functions) can pass +// raw Prisma rows without coercion. Plugins that don't have a Decimal +// type at hand (cloud's Drizzle plugin) return plain `number`. +type DecimalLike = { toNumber(): number }; + +export type AuthenticatedEnvironment = { + id: string; + slug: string; + type: RuntimeEnvironmentType; + apiKey: string; + organizationId: string; + projectId: string; + orgMemberId: string | null; + parentEnvironmentId: string | null; + branchName: string | null; + archivedAt: Date | null; + paused: boolean; + shortcode: string; + maximumConcurrencyLimit: number; + concurrencyLimitBurstFactor: number | DecimalLike; + // Prisma JSON column. Specific flags read it with their own narrower + // types. Pass-through for legacy override paths in marqs / sharedQueue. + builtInEnvironmentVariableOverrides: unknown; + // Bookkeeping timestamps. Prisma rows always have them; non-Prisma + // plugins can fill in with `new Date()` or whatever's appropriate. + createdAt: Date; + updatedAt: Date; + + project: { + id: string; + slug: string; + name: string; + externalRef: string; + engine: RunEngineVersion; + deletedAt: Date | null; + defaultWorkerGroupId: string | null; + // Same id as env.organizationId β€” present on Prisma's Project row + // and read by deployment services that operate on the project alone. + organizationId: string; + // Build-server bookkeeping. Read by remote-image-builder when + // creating Depot builds. + builderProjectId: string | null; + }; + + organization: { + id: string; + slug: string; + title: string; + streamBasinName: string | null; + maximumConcurrencyLimit: number | null; + runsEnabled: boolean; + maximumDevQueueSize: number | null; + maximumDeployedQueueSize: number | null; + // Per-org feature flags + rate-limit config. Loosely typed (Prisma + // JSON) β€” handlers that care about specific keys read with their + // own narrower types. + featureFlags: unknown; + apiRateLimiterConfig: unknown; + batchRateLimitConfig: unknown; + batchQueueConcurrencyConfig: unknown; + }; + + // `user` is optional because most call sites only fetch `userId`. + // Code paths that need user details (display name etc.) include it + // explicitly in their Prisma query. The whole field is optional too + // so admin construction sites that build env literals without it + // satisfy the type. + orgMember?: { + userId: string; + user?: { id: string; displayName: string | null; name: string | null }; + } | null; + + // Optional + nullable: optional so admin routes that don't explicitly + // include parentEnvironment satisfy the type; nullable so Prisma rows + // with a null left-join result satisfy too. + parentEnvironment?: { id: string; apiKey: string } | null; +}; diff --git a/apps/webapp/app/v3/gitBranch.ts b/packages/core/src/v3/utils/gitBranch.ts similarity index 73% rename from apps/webapp/app/v3/gitBranch.ts rename to packages/core/src/v3/utils/gitBranch.ts index 06c76f06241..b1f2f2df27a 100644 --- a/apps/webapp/app/v3/gitBranch.ts +++ b/packages/core/src/v3/utils/gitBranch.ts @@ -1,35 +1,23 @@ export function isValidGitBranchName(branch: string): boolean { - // Must not be empty if (!branch) return false; - // Disallowed characters: space, ~, ^, :, ?, *, [, \ if (/[ \~\^:\?\*\[\\]/.test(branch)) return false; - // Disallow ASCII control characters (0-31) and DEL (127) for (let i = 0; i < branch.length; i++) { const code = branch.charCodeAt(i); if ((code >= 0 && code <= 31) || code === 127) return false; } - // Cannot start or end with a slash if (branch.startsWith("/") || branch.endsWith("/")) return false; - - // Cannot have consecutive slashes if (branch.includes("//")) return false; - - // Cannot contain '..' if (branch.includes("..")) return false; - - // Cannot contain '@{' if (branch.includes("@{")) return false; - - // Cannot end with '.lock' if (branch.endsWith(".lock")) return false; return true; } -export function sanitizeBranchName(ref: string | undefined): string | null { +export function sanitizeBranchName(ref: string | null | undefined): string | null { if (!ref) return null; if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); @@ -37,7 +25,6 @@ export function sanitizeBranchName(ref: string | undefined): string | null { if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length); if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length); if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length); - //unknown ref format, so reject if (ref.startsWith("refs/")) return null; return ref; diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md new file mode 100644 index 00000000000..ed4841c95e1 --- /dev/null +++ b/packages/plugins/CHANGELOG.md @@ -0,0 +1,7 @@ +# @trigger.dev/plugins + +## 0.0.0-prerelease-20260506134321 + +### Patch Changes + +- b3a967765: The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 00000000000..06af5ce94db --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,46 @@ +{ + "name": "@trigger.dev/plugins", + "version": "4.4.4", + "description": "Plugin contracts and interfaces for Trigger.dev", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/plugins" + }, + "type": "module", + "files": [ + "dist" + ], + "dependencies": { + "@trigger.dev/core": "workspace:*" + }, + "scripts": { + "clean": "rimraf dist .turbo", + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.14.14", + "rimraf": "6.0.1", + "tsup": "^8.4.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.20.0" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + } +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts new file mode 100644 index 00000000000..9a03d93b66b --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,23 @@ +export type { + RoleBasedAccessControlPlugin, + RoleBaseAccessController, + RoleAssignmentResult, + RoleMutationResult, + Permission, + Role, + RbacAbility, + RbacSubject, + RbacResource, + RbacEnvironment, + RbacUser, + BearerAuthResult, + SessionAuthResult, + PatAuthResult, + SystemRole, + AuthenticatedEnvironment, +} from "./rbac.js"; + +// Convenience re-exports β€” gives plugin authors (and the cloud workspace +// link) one import surface without reaching into @trigger.dev/core +// directly. Both helpers live in core; this is purely a forwarder. +export { sanitizeBranchName, isValidGitBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts new file mode 100644 index 00000000000..c5ceeabbb68 --- /dev/null +++ b/packages/plugins/src/rbac.ts @@ -0,0 +1,261 @@ +/** + * Plugin-owned metadata for a built-in system role. The plugin returns + * these in canonical order (highest authority first) so the dashboard + * can render columns / build a level ladder without knowing role names. + * + * Roles the plugin doesn't expose at all (e.g. seeded but with the + * `is_hidden` flag set in the cloud plugin) are not returned by + * `systemRoles()` β€” there's no "advertised but absent" state. + * + * `available` indicates whether the role is assignable on the *org's + * plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds + * Developer. Consumers may render unavailable rows with an upgrade + * badge, hide them, or otherwise gate UI on the flag. + */ +export type SystemRole = { + id: string; + name: string; + description: string; + available: boolean; +}; + +export type Permission = { + // `:` β€” display name, derived from the ability rule. + name: string; + description: string; + // Display bucket for the Roles page (e.g. "Runs", "Tasks"). The page + // groups permissions by this string and lists groups in the order they + // first appear in `allPermissions()`, so the plugin owns both the + // bucket label and the section ordering. Omit for "no grouping". + group?: string; + // Inverted rules (CASL `cannot`) surface as βœ— in the Roles page. + inverted?: boolean; + // CASL conditions (e.g. `{ envType: "PRODUCTION" }`) β€” when present, + // the Roles page renders a tier badge alongside the permission row. + conditions?: Record; +}; + +export type Role = { + id: string; + name: string; + description: string; + permissions: Permission[]; + isSystem: boolean; +}; + +export type RbacSubject = + | { type: "user"; userId: string; organizationId: string; projectId?: string } + | { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string } + | { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string }; + +export type RbacResource = { + type: string; + id?: string; + // Extra fields a route may pass for condition-based ability checks β€” + // e.g. `envType` for env-tier-scoped rules ("Member can read envvars + // unless envType === 'PRODUCTION'"). The plugin's ability matcher + // (CASL) reads these off the resource object; routes that don't use + // conditional rules can keep passing `{ type, id? }`. + [key: string]: unknown; +}; + +// The plugin contract carries the same env shape that host webapps' auth +// flows use. Defined in @trigger.dev/core so it's importable from any +// internal package without going through the plugin contract itself. +export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +import type { AuthenticatedEnvironment as RbacEnv } from "@trigger.dev/core/v3/auth/environment"; + +/** @deprecated Renamed to `AuthenticatedEnvironment`. Kept as alias for transitional code. */ +export type RbacEnvironment = RbacEnv; + +export type RbacUser = { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + confirmedBasicDetails: boolean; + isImpersonating: boolean; +}; + +/** Pre-built ability returned by authenticate* β€” all checks are sync, no DB call. */ +export interface RbacAbility { + // Array form means "grant access if any resource in the array passes" β€” + // used by routes that touch multiple resources (e.g. a run also carries + // a batch id, tags, a task identifier) so a JWT scoped to any of them + // grants access. + can(action: string, resource: RbacResource | RbacResource[]): boolean; + canSuper(): boolean; +} + +export type BearerAuthResult = + | { ok: false; status: 401 | 403; error: string } + | { + ok: true; + environment: RbacEnv; + subject: RbacSubject; + ability: RbacAbility; + jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean }; + }; + +export type SessionAuthResult = + | { ok: false; reason: "unauthenticated" | "unauthorized" } + | { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility }; + +// PAT auth deliberately omits `environment` β€” PATs are user identity +// tokens, not environment tokens. The ability is resolved per-request +// from the user's role in the target org (passed via `context`), +// intersected with the PAT's optional max-role cap. +export type PatAuthResult = + | { ok: false; status: 401 | 403; error: string } + | { + ok: true; + tokenId: string; + userId: string; + subject: RbacSubject; + ability: RbacAbility; + }; + +export interface RoleBaseAccessController { + // API routes (Bearer token): one DB query β†’ identity + pre-built ability + // options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys + authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise; + + // Dashboard loaders/actions (session cookie): one DB query β†’ user + pre-built ability. + // The caller resolves `userId` from the session cookie and passes it in. + // (`null` means "no authenticated user"; the plugin returns `{ ok: false, + // reason: "unauthenticated" }`.) The plugin used to take a + // `helpers.getSessionUserId(request)` callback at create-time; pulling the + // userId resolution into the caller drops a static module-load coupling + // from the plugin's host module to the host's session-cookie code. + authenticateSession( + request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string } + ): Promise; + + // PAT-authenticated routes (Authorization: Bearer tr_pat_…). The token + // identifies the user; the effective ability is `min(user's current + // role in the target org, the PAT's optional max-role cap)`. The user's + // actual org membership is the floor β€” if they've been demoted or + // removed, the PAT auto-narrows. The cap is set at PAT creation and + // ceilings the token even when the user is more privileged. + // + // No plugin installed β†’ fallback returns a permissive ability so PAT + // routes that don't yet declare an `authorization` block keep working + // exactly as they did pre-RBAC. + authenticatePat( + request: Request, + context: { organizationId?: string; projectId?: string } + ): Promise; + + // Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails. + // resource accepts the same single-or-array shape as RbacAbility.can β€” array form means + // "grant access if any element passes". + authenticateAuthorizeBearer( + request: Request, + check: { action: string; resource: RbacResource | RbacResource[] }, + options?: { allowJWT?: boolean } + ): Promise; + + authenticateAuthorizeSession( + request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string }, + check: { action: string; resource: RbacResource | RbacResource[] } + ): Promise; + + // Plugin-owned catalogue of built-in system roles for the given org, + // in canonical order (highest authority first). Returns null when no + // plugin is installed β€” there are no seeded roles to refer to in that + // case (the default fallback's `allRoles` returns []). + // + // Hidden roles (e.g. Member in v1) are filtered out entirely. Each + // entry's `available` flag reflects whether the org's plan permits + // assigning that role; consumers can render unavailable entries with + // an upgrade badge or hide them. + systemRoles(organizationId: string): Promise; + + // Role introspection. The fallback returns []; a plugin may return + // its own role catalogue. + allPermissions(organizationId: string): Promise; + allRoles(organizationId: string): Promise; + + // Of the roles returned by `allRoles(organizationId)`, which IDs may + // be assigned right now? Used by the Teams page UI to disable + // role-dropdown options the org isn't allowed to assign. The default + // fallback returns every role id (permissive β€” it doesn't apply any + // gating). Server-side enforcement lives in setUserRole; this method + // is purely a UI affordance. + getAssignableRoleIds(organizationId: string): Promise; + + // Role management. Mutation methods return a discriminated Result + // rather than throwing β€” the dashboard surfaces `error` strings + // directly to the user (system role edits, gating, validation + // conflicts), so a thrown exception is only ever for unexpected + // failures (DB outage, bug). The default fallback returns + // `{ ok: false, error: "RBAC plugin not installed" }` for these. + createRole(params: { + organizationId: string; + name: string; + description: string; + permissions: string[]; + }): Promise; + + updateRole(params: { + roleId: string; + name?: string; + description?: string; + permissions?: string[]; + }): Promise; + + deleteRole(roleId: string): Promise; + + // Role assignments. Same Result discipline as the role-management + // methods above. The default fallback returns + // `{ ok: false, error: "RBAC plugin not installed" }`. + getUserRole(params: { + userId: string; + organizationId: string; + projectId?: string; + }): Promise; + + // Batch variant for callers that need per-user roles for many users + // in one round-trip (e.g. the Team page rendering N members). + // Org-scoped only β€” project-scoped reads still go through getUserRole. + // Returns a Map keyed by userId; users with no resolvable role map to + // null. The default fallback returns a Map of all userIds β†’ null. + getUserRoles( + userIds: string[], + organizationId: string + ): Promise>; + + setUserRole(params: { + userId: string; + organizationId: string; + roleId: string; + projectId?: string; + }): Promise; + + removeUserRole(params: { + userId: string; + organizationId: string; + projectId?: string; + }): Promise; + + getTokenRole(tokenId: string): Promise; + setTokenRole(params: { tokenId: string; roleId: string }): Promise; + removeTokenRole(tokenId: string): Promise; +} + +// Mutation result for role create/update β€” success carries the new +// `role`, failure carries a user-facing `error` string. +export type RoleMutationResult = + | { ok: true; role: Role } + | { ok: false; error: string }; + +// Result for assignment / deletion mutations that don't return a value. +export type RoleAssignmentResult = { ok: true } | { ok: false; error: string }; + +export interface RoleBasedAccessControlPlugin { + create(): RoleBaseAccessController | Promise; +} diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json new file mode 100644 index 00000000000..e16a109bd98 --- /dev/null +++ b/packages/plugins/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "compilerOptions": { + "sourceMap": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/tsup.config.ts b/packages/plugins/tsup.config.ts new file mode 100644 index 00000000000..4dff9109b7f --- /dev/null +++ b/packages/plugins/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7c70806a1d..e015181ef6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: '@trigger.dev/platform': specifier: 1.0.27 version: 1.0.27 + '@trigger.dev/rbac': + specifier: workspace:* + version: link:../../internal-packages/rbac '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -1147,7 +1150,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1200,6 +1203,25 @@ importers: specifier: ^1.167.3 version: 1.167.3 + internal-packages/rbac: + dependencies: + '@trigger.dev/core': + specifier: workspace:* + version: link:../../packages/core + '@trigger.dev/plugins': + specifier: workspace:* + version: link:../../packages/plugins + devDependencies: + '@trigger.dev/database': + specifier: workspace:* + version: link:../database + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + internal-packages/redis: dependencies: '@trigger.dev/core': @@ -1888,6 +1910,25 @@ importers: specifier: 4.17.0 version: 4.17.0 + packages/plugins: + dependencies: + '@trigger.dev/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + tsup: + specifier: ^8.4.0 + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(typescript@5.5.4)(yaml@2.8.3) + typescript: + specifier: 5.5.4 + version: 5.5.4 + packages/python: dependencies: '@trigger.dev/core': @@ -19011,10 +19052,6 @@ packages: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.12: - resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -23059,7 +23096,7 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -23745,7 +23782,7 @@ snapshots: dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': dependencies: '@hono/node-server': 1.12.2(hono@4.5.11) ws: 8.18.3(bufferutil@4.0.9) @@ -38484,6 +38521,15 @@ snapshots: tsx: 4.17.0 yaml: 2.8.3 + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(yaml@2.8.3): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.4.2 + postcss: 8.5.10 + tsx: 4.20.6 + yaml: 2.8.3 + postcss-loader@8.1.1(postcss@8.5.10)(typescript@5.5.4)(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)): dependencies: cosmiconfig: 9.0.0(typescript@5.5.4) @@ -39057,7 +39103,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -39094,8 +39140,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3(bufferutil@4.0.9) - socket.io-client: 4.7.3(bufferutil@4.0.9) + socket.io: 4.7.3 + socket.io-client: 4.7.3 sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -40322,7 +40368,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3(bufferutil@4.0.9): + socket.io-client@4.7.3: dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -40351,7 +40397,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3(bufferutil@4.0.9): + socket.io@4.7.3: dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -41156,11 +41202,6 @@ snapshots: fdir: 6.4.3(picomatch@4.0.4) picomatch: 4.0.4 - tinyglobby@0.2.12: - dependencies: - fdir: 6.4.4(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.4) @@ -41340,7 +41381,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0 + debug: 4.4.3(supports-color@10.0.0) esbuild: 0.25.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -41350,7 +41391,35 @@ snapshots: source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.15) + postcss: 8.5.10 + typescript: 5.5.4 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(typescript@5.5.4)(yaml@2.8.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.1) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@10.0.0) + esbuild: 0.25.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(yaml@2.8.3) + resolve-from: 5.0.0 + rollup: 4.60.1 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.15)