diff --git a/.changeset/rbac-assignable-role-ids.md b/.changeset/rbac-assignable-role-ids.md new file mode 100644 index 00000000000..d074b32adb9 --- /dev/null +++ b/.changeset/rbac-assignable-role-ids.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +RBAC plugin: new `getAssignableRoleIds(organizationId)` method on `RoleBaseAccessController`. Returns the subset of `allRoles(organizationId)` IDs that may be assigned right now — used by the Teams page UI to disable role-dropdown options that aren't currently assignable. The default fallback returns `[]` (permissive — `allRoles` already returns `[]` so there's nothing to gate); a plugin may apply its own gating policy and return the assignable subset. Server-side enforcement (rejecting an actual `setUserRole` to a non-assignable role) is unchanged and remains the source of truth — this method is purely a UI affordance. diff --git a/.changeset/rbac-authenticate-authorize-arrays.md b/.changeset/rbac-authenticate-authorize-arrays.md new file mode 100644 index 00000000000..796cb67d09b --- /dev/null +++ b/.changeset/rbac-authenticate-authorize-arrays.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": minor +--- + +RBAC plugin: `RoleBaseAccessController.authenticateAuthorizeBearer` and `authenticateAuthorizeSession` now accept `RbacResource | RbacResource[]` for `check.resource`, matching `RbacAbility.can`. This was an inconsistency — abilities accepted arrays but the convenience methods didn't, so callers wanting the array form had to call `authenticateBearer` + `ability.can` manually. diff --git a/.changeset/rbac-mutation-result-types.md b/.changeset/rbac-mutation-result-types.md new file mode 100644 index 00000000000..8f1cc8b6eb6 --- /dev/null +++ b/.changeset/rbac-mutation-result-types.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +RBAC plugin: mutation methods on `RoleBaseAccessController` now return discriminated `Result` types instead of throwing on expected error paths. `createRole` and `updateRole` return `RoleMutationResult` (`{ ok: true; role: Role } | { ok: false; error: string }`); `deleteRole`, `setUserRole`, `removeUserRole`, `setTokenRole`, and `removeTokenRole` return `RoleAssignmentResult` (`{ ok: true } | { ok: false; error: string }`). The webapp surfaces the `error` strings directly to users (system role edits, plan-tier gating, validation conflicts) so a thrown exception now signals only an unexpected failure (DB outage, bug). Read methods (`getUserRole`, `getTokenRole`, `allRoles`, `allPermissions`) are unchanged. diff --git a/.changeset/rbac-plugin-array-resources.md b/.changeset/rbac-plugin-array-resources.md new file mode 100644 index 00000000000..79a09c9e4bb --- /dev/null +++ b/.changeset/rbac-plugin-array-resources.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": minor +--- + +RBAC plugin: `RbacAbility.can(action, resource)` now accepts either a single `RbacResource` or `RbacResource[]`. Array form means "grant access if any resource in the array passes", matching the multi-key authorisation semantics expected by routes that touch multiple resources (e.g. a run that also carries a batch id, tags, and a task identifier — a JWT scoped to any of them grants access). diff --git a/.changeset/rbac-system-role-ids-method.md b/.changeset/rbac-system-role-ids-method.md new file mode 100644 index 00000000000..4c82063134b --- /dev/null +++ b/.changeset/rbac-system-role-ids-method.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +RBAC plugin: new `systemRoleIds(): Promise` method on `RoleBaseAccessController`. Returns `{ owner, admin, developer, member }` with the seed-migration role IDs when a plugin is loaded; returns `null` when no plugin is installed (matches the `allRoles → []` semantics — there are no seeded roles to refer to). Lets consumers (invite flow, PAT defaults, Roles page comparison grid) get the canonical IDs without duplicating constants in the consuming app. diff --git a/.changeset/rbac-system-roles.md b/.changeset/rbac-system-roles.md new file mode 100644 index 00000000000..a28b498bba2 --- /dev/null +++ b/.changeset/rbac-system-roles.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +RBAC plugin: replaced `systemRoleIds()` with `systemRoles()`. The new method returns an ordered `SystemRole[]` (highest authority first) where each entry carries `id`, `name`, `description`, and `available`. The OSS no longer needs to know individual role names — it just iterates the canonical order from the plugin. `available: false` lets a plugin advertise a role without exposing it (used by v1 to ship Owner/Admin/Developer while keeping Member's prod-restriction promise unmade until the env-tier route wiring lands). diff --git a/.github/workflows/e2e-webapp-auth-full.yml b/.github/workflows/e2e-webapp-auth-full.yml new file mode 100644 index 00000000000..34a30d3c5f5 --- /dev/null +++ b/.github/workflows/e2e-webapp-auth-full.yml @@ -0,0 +1,116 @@ +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@v4 + with: + fetch-depth: 0 + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: ⎔ Setup node + uses: buildjet/setup-node@v4 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 🐳 Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@v3 + 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/rbac-apibuilder-migration.md b/.server-changes/rbac-apibuilder-migration.md new file mode 100644 index 00000000000..b39c11036f3 --- /dev/null +++ b/.server-changes/rbac-apibuilder-migration.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Migrate `apiBuilder.server.ts` to `rbac.authenticateBearer` + `ability.can` (TRI-8719). All 30 API routes that used `authorization.superScopes` now rely on the RBAC plugin's scope-algebra plus an `ACTION_ALIASES` compatibility map (`trigger`/`batchTrigger`/`update` satisfied by `write:*` scopes). Two intentional improvements: empty-resource routes (`/api/v1/batches`, `/api/v1/idempotencyKeys/:key/reset`) now accept JWTs (previously denied by the legacy empty-resource short-circuit); id-scoped `write:tasks:*` scopes now grant `POST /api/v1/tasks/:taskId/trigger` (previously denied by literal superScope mismatch). Both are strict supersets of the current accept set — no JWT regresses. diff --git a/.server-changes/rbac-dashboard-builder.md b/.server-changes/rbac-dashboard-builder.md new file mode 100644 index 00000000000..fe83ef72222 --- /dev/null +++ b/.server-changes/rbac-dashboard-builder.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Add `dashboardLoader` / `dashboardAction` route builders that route session auth through the RBAC plugin (`rbac.authenticateSession` + `ability.canSuper()` / `ability.can`) and migrate the platform admin pages onto them. Routes that only need a logged-in user with no authorisation continue to use `requireUser` / `requireUserId` — the builder is opt-in for routes with explicit auth checks. Migrated routes: `admin.tsx`, `admin._index.tsx`, `admin.concurrency.tsx`, `admin.feature-flags.tsx`, `admin.notifications.tsx`, `admin.orgs.tsx`, `admin.data-stores.tsx`, `admin.back-office.tsx` (+ children), `admin.llm-models.*` (5 routes). Behavioural change: actions that previously threw `403 Unauthorized` on non-admins now redirect to `/` along with the loader — uniform with the builder's behaviour. diff --git a/.server-changes/rbac-force-fallback.md b/.server-changes/rbac-force-fallback.md new file mode 100644 index 00000000000..317e2496b88 --- /dev/null +++ b/.server-changes/rbac-force-fallback.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +RBAC plugin: add RBAC_FORCE_FALLBACK env var so tests can pin the loader to the default fallback without depending on whether a plugin happens to be installed in node_modules. diff --git a/.server-changes/rbac-invite-role-picker.md b/.server-changes/rbac-invite-role-picker.md new file mode 100644 index 00000000000..b14da907c89 --- /dev/null +++ b/.server-changes/rbac-invite-role-picker.md @@ -0,0 +1,18 @@ +--- +area: webapp +type: feature +--- + +RBAC: invite flow now lets the inviter pick the new member's role. +The dropdown is filtered to roles the inviter is allowed to assign +(strictly below their own level — Owner > Admin > Developer > Member) +and gated by the org's plan tier (so Free/Hobby see Owner+Admin, Pro+ +unlocks Developer+Member). With no RBAC plugin installed the picker +is hidden entirely and the legacy invite flow is unchanged. + +Schema: new nullable `OrgMemberInvite.rbacRoleId text` column. Legacy +`role` enum stays untouched and is set to ADMIN or MEMBER based on +the chosen RBAC role for OSS-side auth compatibility. On accept, when +`rbacRoleId` is non-null the plugin's `setUserRole` is called after +the OrgMember insert; otherwise the runtime fallback derives the role +from the legacy `role` field. diff --git a/.server-changes/rbac-pat-role-selection.md b/.server-changes/rbac-pat-role-selection.md new file mode 100644 index 00000000000..9f5fe5fe062 --- /dev/null +++ b/.server-changes/rbac-pat-role-selection.md @@ -0,0 +1,15 @@ +--- +area: webapp +type: feature +--- + +RBAC: PAT creation flow now lets users pick a system role at create +time, persisted via the RBAC plugin's `setTokenRole`. Defaults to the +caller's own role so a PAT can't be more privileged than the person +creating it. Custom (org-defined) roles are out of scope for v1 — only +the four global system roles are offered, and the binding is global to +the PAT regardless of which org the request later targets. A +compensating-delete on `setTokenRole` failure keeps the PAT row and +the role row consistent without cross-store transaction wrestling. +With no RBAC plugin installed the dropdown is hidden, no roleId is +submitted, and the PAT works exactly as before. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index c8cd131d962..fed07fbb520 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,14 @@ export function OrganizationSettingsSideMenu({ to={organizationTeamPath(organization)} data-action="team" /> + : 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 ff27168445a..c707a148033 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1512,6 +1512,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..713b9669187 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 } } }, @@ -99,6 +110,24 @@ export async function inviteMembers({ throw new Error("User does not have access to this organization"); } + // The legacy OrgMember.role enum (ADMIN | MEMBER) needs a write so + // pre-RBAC code paths still see a sensible role on the invite. Map by + // role NAME — "Owner" and "Admin" become "ADMIN", everything else + // becomes "MEMBER". This is the one place left where the OSS keys off + // role names; the plugin owns the systemRoles catalogue and we just + // match on the well-known legacy-equivalent labels here. + // null means no plugin installed → default to "MEMBER" (legacy two- + // option flow). + const sys = await rbac.systemRoles(org.id); + const adminEquivalent = new Set( + (sys ?? []) + .filter((r) => r.name === "Owner" || r.name === "Admin") + .map((r) => r.id) + ); + const legacyRole: "ADMIN" | "MEMBER" = adminEquivalent.has(rbacRoleId ?? "") + ? "ADMIN" + : "MEMBER"; + const invites = [...new Set(emails)].map( (email) => ({ @@ -106,7 +135,8 @@ export async function inviteMembers({ token: tokenGenerator(), organizationId: org.id, inviterId: userId, - role: "MEMBER", + role: legacyRole, + rbacRoleId: rbacRoleId ?? null, } satisfies Prisma.OrgMemberInviteCreateManyInput) ); @@ -163,7 +193,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 +237,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/organization.server.ts b/apps/webapp/app/models/organization.server.ts index 14315dd337c..8e9baf5e5fb 100644 --- a/apps/webapp/app/models/organization.server.ts +++ b/apps/webapp/app/models/organization.server.ts @@ -82,6 +82,12 @@ export async function createOrganization( }, }); + // No upfront RBAC UserRole insert — the loaded RBAC plugin (if any) + // is responsible for deriving the creator's role from the legacy + // OrgMember.role = "ADMIN" write above (which maps to Owner) until an + // Owner explicitly changes someone's role on the Teams page. Keeps + // the create-org path tight and lets the plugin stay the single + // source of truth for "who has what role" by default. return { ...organization }; } 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/presenters/TeamPresenter.server.ts b/apps/webapp/app/presenters/TeamPresenter.server.ts index 8b84a65a67c..a7f0cfc190c 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,33 @@ 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, memberRoles] = + 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. N+1 by design: this page is rendered + // for admins on a low-traffic settings screen, and the rbac plugin + // doesn't currently expose a batched lookup. Switching to a single + // Drizzle query keyed on (orgId, userIds[]) is a future optimisation. + Promise.all( + result.members.map(async (m) => ({ + userId: m.user.id, + role: await rbac.getUserRole({ + userId: m.user.id, + organizationId, + }), + })) + ), + ]); const canPurchaseSeats = currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true; @@ -38,6 +61,9 @@ export class TeamPresenter extends BasePresenter { seatPricing, maxSeatQuota, planSeatLimit, + roles, + assignableRoleIds, + memberRoles, }; } } 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..3d7f83d42d4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -25,6 +25,7 @@ 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"; @@ -32,6 +33,7 @@ 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 { 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) strictly 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) && + isStrictlyBelow(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 strictly 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 isStrictlyBelow( + 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 strictly 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 ( + !isStrictlyBelow( + 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..55016ed730a --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -0,0 +1,404 @@ +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 ({ params }) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + 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]; + +// Permission name → display group. The wire-format Permission only +// carries `name` and `description`, so this lives client-side. +const PERMISSION_GROUP_BY_NAME: Record = { + "read:runs": "Runs", + "write:runs": "Runs", + "read:tags": "Runs", + "read:batch": "Runs", + "write:batch": "Runs", + "read:tasks": "Tasks", + "write:tasks": "Tasks", + "trigger:tasks": "Tasks", + "batchTrigger:tasks": "Tasks", + "deploy:tasks": "Tasks", + "read:waitpoints": "Waitpoints", + "write:waitpoints": "Waitpoints", + "read:inputStreams": "Realtime", + "write:inputStreams": "Realtime", + "read:deployments": "Deployments", + "read:prompts": "Prompts", + "write:prompts": "Prompts", + "update:prompts": "Prompts", + "read:query": "Query", + "read:tokens": "Tokens", + "write:tokens": "Tokens", + "read:envvars": "Environment", + "write:envvars": "Environment", + "read:apiKeys": "Environment", + "write:apiKeys": "Environment", + "read:members": "Organisation", + "manage:members": "Organisation", + "manage:billing": "Organisation", +}; + +const GROUP_ORDER = [ + "Runs", + "Tasks", + "Waitpoints", + "Realtime", + "Deployments", + "Prompts", + "Query", + "Tokens", + "Environment", + "Organisation", + "Other", +] as const; + +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[] }[] { + const buckets = new Map(); + for (const permission of permissions) { + const group = PERMISSION_GROUP_BY_NAME[permission.name] ?? "Other"; + const list = buckets.get(group) ?? []; + list.push(permission); + buckets.set(group, list); + } + return GROUP_ORDER.flatMap((group) => + buckets.has(group) ? [{ group, permissions: buckets.get(group)! }] : [] + ); +} + +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..b52b4a0e733 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, params }) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + 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 }) => { + 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 = await resolveOrgIdFromSlug(organizationSlug); + 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,7 +383,17 @@ export default function Page() { )} - Active team members +
+ Active team members + {roles.length > 0 ? ( + + View all role permissions → + + ) : null} +
    {members.map((member) => (
  • @@ -310,10 +412,18 @@ export default function Page() { {member.user.email}
    +
  • @@ -387,10 +497,12 @@ function LeaveRemoveButton({ userId, member, memberCount, + canManageMembers, }: { userId: string; member: Member; memberCount: number; + canManageMembers: boolean; }) { const organization = useOrganization(); @@ -409,7 +521,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; + + return ( +
    + + {error ? ( + + {error} + + ) : null} +
    + ); +} + function LeaveTeamModal({ member, buttonText, diff --git a/apps/webapp/app/routes/account.tokens/route.tsx b/apps/webapp/app/routes/account.tokens/route.tsx index 4ad62d7edc8..f6b13ace04a 100644 --- a/apps/webapp/app/routes/account.tokens/route.tsx +++ b/apps/webapp/app/routes/account.tokens/route.tsx @@ -5,6 +5,7 @@ import { ShieldExclamationIcon } from "@heroicons/react/24/solid"; import { DialogClose } from "@radix-ui/react-dialog"; import { Form, type MetaFunction, useActionData, useFetcher } from "@remix-run/react"; import { type ActionFunction, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { PageBody, PageContainer } from "~/components/layout/AppLayout"; @@ -22,6 +23,7 @@ import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { Table, TableBlankRow, @@ -34,6 +36,8 @@ import { } from "~/components/primitives/Table"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { redirectWithSuccessMessage } from "~/models/message.server"; +import { prisma } from "~/db.server"; +import { rbac } from "~/services/rbac.server"; import { type CreatedPersonalAccessToken, type ObfuscatedPersonalAccessToken, @@ -52,14 +56,76 @@ export const meta: MetaFunction = () => { ]; }; +// PATs aren't org-scoped, but the RBAC plugin's allRoles is org-keyed +// (a plugin may also expose org-defined custom roles alongside the +// global system roles). The picker shows the assignable system role +// catalogue for the user's primary org — joining `allRoles` (for the +// full Role with permissions) against `systemRoles` (for the per-org +// `available` flag, which gates roles by plan tier). This is a UI-only +// convenience — the chosen role becomes a global TokenRole that +// applies wherever the PAT is used. Custom (org-defined) roles are +// out of scope for v1: their org-binding semantics for a multi-org +// user's PAT need a separate design pass. +async function loadSystemRolesForUser(userId: string) { + const orgMember = await prisma.orgMember.findFirst({ + where: { userId }, + select: { organizationId: true }, + orderBy: { createdAt: "asc" }, + }); + if (!orgMember) { + return { + roles: [], + userRoleId: null as string | null, + orgId: null as string | null, + }; + } + + const [allRoles, systemRoles, userRole] = await Promise.all([ + rbac.allRoles(orgMember.organizationId), + rbac.systemRoles(orgMember.organizationId), + rbac.getUserRole({ userId, organizationId: orgMember.organizationId }), + ]); + + // Restrict the picker to system roles the plan permits assigning — + // anything else would be a noisy create-time failure (or, with a + // permissive fallback, a token bound to a role this org isn't + // allowed to issue). + const availableIds = new Set( + (systemRoles ?? []).filter((r) => r.available).map((r) => r.id) + ); + const roles = allRoles.filter((r) => r.isSystem && availableIds.has(r.id)); + + return { + roles, + userRoleId: userRole?.id ?? null, + orgId: orgMember.organizationId, + }; +} + export const loader = async ({ request }: LoaderFunctionArgs) => { const userId = await requireUserId(request); try { - const personalAccessTokens = await getValidPersonalAccessTokens(userId); + const [personalAccessTokens, { roles, userRoleId, orgId }] = await Promise.all([ + getValidPersonalAccessTokens(userId), + loadSystemRolesForUser(userId), + ]); + + // Default the role picker to the user's own role in their primary + // org so a freshly-created PAT isn't more privileged than the + // person creating it. Falls back to the most-restrictive role + // available on the org's plan if they don't have one. When the + // user isn't a member of any org or no RBAC plugin is installed, + // the picker is hidden anyway, so defaultRoleId is just a + // placeholder. + const sys = orgId ? await rbac.systemRoles(orgId) : null; + const lowestAvailable = (sys ?? []).filter((r) => r.available).at(-1)?.id ?? ""; + const defaultRoleId = userRoleId ?? lowestAvailable; return typedjson({ personalAccessTokens, + roles, + defaultRoleId, }); } catch (error) { if (error instanceof Response) { @@ -81,6 +147,10 @@ const CreateTokenSchema = z.discriminatedUnion("action", [ .string({ required_error: "You must enter a name" }) .min(2, "Your name must be at least 2 characters long") .max(50), + // Optional — when no RBAC plugin is installed the UI hides the + // dropdown and submits no roleId; the action passes that through + // and createPersonalAccessToken just doesn't write a TokenRole. + roleId: z.string().optional(), }), z.object({ action: z.literal("revoke"), @@ -100,9 +170,27 @@ export const action: ActionFunction = async ({ request }) => { switch (submission.value.action) { case "create": { try { + // Revalidate the submitted roleId against the plan-allowed set + // — the loader filters the picker, but a hand-crafted POST can + // still submit any string. Empty / undefined is fine: that + // means "no role" and createPersonalAccessToken just doesn't + // write a TokenRole. + const submittedRoleId = submission.value.roleId; + if (submittedRoleId) { + const { roles } = await loadSystemRolesForUser(userId); + const allowed = new Set(roles.map((r) => r.id)); + if (!allowed.has(submittedRoleId)) { + return json( + { errors: { body: "Selected role isn't available on this plan" } }, + { status: 400 } + ); + } + } + const tokenResult = await createPersonalAccessToken({ name: submission.value.tokenName, userId, + roleId: submittedRoleId, }); return json({ ...submission, payload: { token: tokenResult } }); @@ -131,7 +219,7 @@ export const action: ActionFunction = async ({ request }) => { }; export default function Page() { - const { personalAccessTokens } = useTypedLoaderData(); + const { personalAccessTokens, roles, defaultRoleId } = useTypedLoaderData(); return ( @@ -151,7 +239,7 @@ export default function Page() { Create a Personal Access Token - + @@ -211,7 +299,15 @@ export default function Page() { ); } -function CreatePersonalAccessToken() { +type SystemRole = { id: string; name: string; description: string }; + +function CreatePersonalAccessToken({ + roles, + defaultRoleId, +}: { + roles: SystemRole[]; + defaultRoleId: string; +}) { const fetcher = useFetcher(); const lastSubmission = fetcher.data as any; @@ -228,6 +324,14 @@ function CreatePersonalAccessToken() { ? (lastSubmission?.payload?.token as CreatedPersonalAccessToken) : undefined; + // With no RBAC plugin installed, rbac.allRoles returns []; hide the + // dropdown entirely rather than showing an empty Select. + // createPersonalAccessToken's roleId is optional, so omitting it + // produces a working PAT with no explicit role attached (matches + // pre-RBAC behaviour). + const showRolePicker = roles.length > 0; + const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId); + return (
    {token ? ( @@ -248,6 +352,7 @@ function CreatePersonalAccessToken() { ) : ( + {showRolePicker && }
    @@ -265,6 +370,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's permissions are bound to this role. Defaults to your own role so the + token can't do more than you can. + + + )} + 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.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 22500011671..1dc6aeb3cdf 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -36,9 +36,10 @@ const { action, loader } = createActionApiRoute( action: "read", resource: (_, __, ___, body) => { const tables = detectTables(body.query); - return { query: tables.length > 0 ? tables : "all" }; + return tables.length > 0 + ? 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..a6e258118af 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts @@ -21,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 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..7606a491a07 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 @@ -28,13 +28,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 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..6ea25b86e15 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts @@ -26,13 +26,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 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..332dcdc8f58 100644 --- a/apps/webapp/app/routes/api.v1.runs.ts +++ b/apps/webapp/app/routes/api.v1.runs.ts @@ -13,8 +13,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 [ + { 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.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 e6ada1a739c..c1e415404ca 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.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 8db98b4d343..b7758b1e97f 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 5067eaef06e..a5bb2047bde 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..f6268483cee 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -18,13 +18,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 resources; + }, }, }, async ({ authentication, resource, apiVersion }) => { 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..4124b0085bf 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts @@ -31,13 +31,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 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..676cd5a3dbd 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.ts @@ -21,8 +21,10 @@ 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) => [ + { type: "runs" }, + ...(searchParams.tags ?? []).map((tag) => ({ type: "tags", id: tag })), + ], }, }, async ({ searchParams, authentication, request, apiVersion }) => { 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 aabd83bc9bb..8b79692454d 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -86,7 +86,12 @@ export const loader = createLoaderApiRoute( friendlyId: params.runId, runtimeEnvironmentId: auth.environment.id, }, - include: { + select: { + id: true, + friendlyId: true, + taskIdentifier: true, + runTags: true, + realtimeStreamsVersion: true, batch: { select: { friendlyId: true, @@ -97,13 +102,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 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 b16b1ca7922..bb8c0c02939 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 @@ -31,8 +31,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 }) => { @@ -125,13 +124,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 resources; + }, }, }, async ({ params, request, resource: run, authentication }) => { diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index e781cdfeb7a..7178f72f27e 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,22 @@ 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. +const FALLBACK_NOT_INSTALLED_ERROR = "RBAC fallback 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 +352,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 +367,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 51075c1b87d..3b3e7210451 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"; @@ -587,33 +586,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 { @@ -778,7 +750,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..6004a03eeb5 --- /dev/null +++ b/apps/webapp/app/services/rbac.server.ts @@ -0,0 +1,18 @@ +import { prisma } from "~/db.server"; +import plugin from "@trigger.dev/rbac"; +import { env } from "~/env.server"; +import { getUserId } from "./session.server"; + +async function getSessionUserId(request: Request): Promise { + const id = await getUserId(request); + return id ?? null; +} + +// 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. +export const rbac = plugin.create( + prisma, + { getSessionUserId }, + { forceFallback: env.RBAC_FORCE_FALLBACK } +); diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 9e439938d0d..b9827372884 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -1,17 +1,12 @@ 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 { findEnvironmentById } from "~/models/runtimeEnvironment.server"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import { authenticateApiRequestWithPersonalAccessToken, PersonalAccessTokenAuthenticationResult, @@ -50,8 +45,85 @@ 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; error: string } + | { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility } +> { + const result = await rbac.authenticateBearer(request, { allowJWT }); + if (!result.ok) { + return { ok: false, status: 401, error: result.error }; + } + + // The fallback already filters deleted projects; this is belt-and-braces for + // any race between auth and the follow-up lookup, and fills in the full + // Prisma-shaped AuthenticatedEnvironment that handlers read from. + const environment = await findEnvironmentById(result.environment.id); + if (!environment) { + return { ok: false, status: 401, error: "Invalid API key" }; + } + + const authentication: ApiAuthenticationResultSuccess = { + ok: true, + apiKey: result.environment.apiKey, + type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE", + environment, + realtime: result.jwt?.realtime, + oneTimeUse: result.jwt?.oneTimeUse, + }; + + return { ok: true, authentication, ability: result.ability }; +} + type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; +// Most route auth checks pass an array of resources to ability.can() with +// "any-element-passes" semantics — 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 to the row. +// +// Batch operations are different: each item in the array is a *distinct* +// resource and authorization must hold for every one of them. Wrapping the +// array via `everyResource` flips the auth check from `some` to `every`. The +// marker is a Symbol so it can't collide with arbitrary RbacResource fields. +const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource"); + +type EveryResourceAuth = { + readonly [EVERY_RESOURCE_MARKER]: true; + readonly resources: readonly RbacResource[]; +}; + +export function everyResource(resources: RbacResource[]): EveryResourceAuth { + return { [EVERY_RESOURCE_MARKER]: true, resources }; +} + +function isEveryResource(value: unknown): value is EveryResourceAuth { + return ( + typeof value === "object" && + value !== null && + (value as Record)[EVERY_RESOURCE_MARKER] === true + ); +} + +type AuthResource = RbacResource | RbacResource[] | EveryResourceAuth; + +function checkAuth( + ability: RbacAbility, + action: string, + resource: AuthResource +): boolean { + if (isEveryResource(resource)) { + return resource.resources.every((r) => ability.can(action, r)); + } + return ability.can(action, resource); +} + type ApiKeyRouteBuilderOptions< TParamsSchema extends AnyZodSchema | undefined = undefined, TSearchParamsSchema extends AnyZodSchema | undefined = undefined, @@ -76,7 +148,7 @@ type ApiKeyRouteBuilderOptions< ) => Promise; shouldRetryNotFound?: boolean; authorization?: { - action: AuthorizationAction; + action: string; resource: ( resource: NonNullable, params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion @@ -90,8 +162,7 @@ type ApiKeyRouteBuilderOptions< headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer : undefined - ) => AuthorizationResources; - superScopes?: string[]; + ) => AuthResource; }; }; @@ -144,23 +215,15 @@ export function createLoaderApiRoute< } 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; let parsedParams: any = undefined; if (paramsSchema) { @@ -227,7 +290,7 @@ export function createLoaderApiRoute< } if (authorization) { - const { action, resource: authResource, superScopes } = authorization; + const { action, resource: authResource } = authorization; const $authResource = authResource( resource, parsedParams, @@ -235,26 +298,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", @@ -468,7 +517,7 @@ type ApiKeyActionRouteBuilderOptions< : undefined ) => Promise; authorization?: { - action: AuthorizationAction; + action: string; resource: ( params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer @@ -490,8 +539,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 +627,15 @@ export function createActionApiRoute< } 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; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -706,7 +746,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 +755,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 +851,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 +897,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 +980,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..18ebf954510 --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts @@ -0,0 +1,98 @@ +// 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 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); +} + +export async function authenticateAndAuthorize( + request: Request, + rawParams: unknown, + options: DashboardLoaderOptions +): Promise< + | { ok: false; response: Response } + | { + ok: true; + user: SessionUser; + ability: RbacAbility; + params: unknown; + searchParams: unknown; + } +> { + 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) : {}; + const auth = await rbac.authenticateSession(request, ctx); + 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, + }; +} diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts new file mode 100644 index 00000000000..a38443c82c5 --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts @@ -0,0 +1,134 @@ +// 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[]; + }; + +export type DashboardLoaderOptions = { + params?: TParams; + searchParams?: TSearchParams; + // Optional: provides organizationId / projectId to rbac.authenticateSession + // when the route's ability check needs it. The default fallback + // ignores context; an installed plugin may use it to scope the + // returned ability. + context?: ( + params: InferZod, + request: Request + ) => + | { organizationId?: string; projectId?: string } + | Promise<{ organizationId?: string; projectId?: string }>; + 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; + request: Request; +}; + +export function dashboardLoader< + TParams extends AnyZodSchema | undefined = undefined, + TSearchParams extends AnyZodSchema | undefined = undefined, + 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, + request, + }); + }; +} + +export type DashboardActionOptions = DashboardLoaderOptions< + TParams, + TSearchParams +>; + +export type DashboardActionHandlerArgs = DashboardLoaderHandlerArgs< + TParams, + TSearchParams +> & { + request: Request; +}; + +export function dashboardAction< + TParams extends AnyZodSchema | undefined = undefined, + TSearchParams extends AnyZodSchema | undefined = undefined, + 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, + request, + }); + }; +} 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/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..db2befc7ffc --- /dev/null +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -0,0 +1,2351 @@ +// 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 { 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("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); + }); + }); +}); 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/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/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 c7b5e7ce12b..442ef30d433 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -265,6 +265,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..c9dc2e922f0 --- /dev/null +++ b/internal-packages/rbac/src/ability.test.ts @@ -0,0 +1,130 @@ +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); + }); + + 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..fd4c3759cbb --- /dev/null +++ b/internal-packages/rbac/src/ability.ts @@ -0,0 +1,55 @@ +import type { RbacAbility, RbacResource } from "@trigger.dev/plugins"; + +// Applies a per-resource predicate across single or multi-resource inputs. +// Array form means "any element passes → authorized", matching the legacy +// multi-key checkAuthorization semantic. +function anyResource( + resource: RbacResource | RbacResource[], + predicate: (r: RbacResource) => boolean +): boolean { + return Array.isArray(resource) ? resource.some(predicate) : predicate(resource); +} + +/** 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 { + return { + can(action: string, resource: RbacResource | RbacResource[]): boolean { + return anyResource(resource, (r) => + scopes.some((scope) => { + const [scopeAction, scopeType, scopeId] = scope.split(":"); + if (scopeAction === "admin") 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; + }) + ); + }, + 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..c6c5286c1ff --- /dev/null +++ b/internal-packages/rbac/src/fallback.ts @@ -0,0 +1,297 @@ +import type { + Permission, + Role, + RbacEnvironment, + RbacUser, + RbacSubject, + RbacResource, + BearerAuthResult, + SessionAuthResult, + RoleAssignmentResult, + RoleBaseAccessController, + RoleMutationResult, +} from "@trigger.dev/plugins"; +import type { PrismaClient } from "@trigger.dev/database"; +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js"; + +export class RoleBaseAccessFallback { + constructor(private readonly prisma: PrismaClient) {} + + create( + helpers: { getSessionUserId: (request: Request) => Promise } + ): RoleBaseAccessFallbackController { + return new RoleBaseAccessFallbackController(this.prisma, helpers); + } +} + +class RoleBaseAccessFallbackController implements RoleBaseAccessController { + constructor( + private readonly prisma: PrismaClient, + private readonly helpers: { getSessionUserId: (request: Request) => Promise } + ) {} + + 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" }; + + const env = await this.prisma.runtimeEnvironment.findFirst({ + where: { id: envId }, + include: { + project: true, + organization: true, + parentEnvironment: { select: { 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: toRbacEnvironment(env), + subject: { + type: "publicJWT", + environmentId: env.id, + organizationId: env.organizationId, + projectId: env.projectId, + }, + ability: buildJwtAbility(scopes), + jwt: { realtime, oneTimeUse }, + }; + } + + const env = await this.prisma.runtimeEnvironment.findFirst({ + where: { apiKey: rawToken }, + include: { + project: true, + organization: true, + orgMember: { select: { userId: true } }, + }, + }); + + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid API key" }; + } + + const subject: RbacSubject = { + type: "user", + userId: env.orgMember?.userId ?? "", + organizationId: env.organizationId, + projectId: env.projectId, + }; + + return { + ok: true, + environment: toRbacEnvironment(env), + subject, + ability: permissiveAbility, + }; + } + + async authenticateSession( + request: Request, + context: { organizationId?: string; projectId?: string } + ): Promise { + const userId = await this.helpers.getSessionUserId(request); + if (!userId) return { ok: false, reason: "unauthenticated" }; + + const user = await this.prisma.user.findFirst({ where: { id: 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: { 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 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 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; + } +} + +function toRbacEnvironment( + env: { + id: string; + slug: string; + type: string; + apiKey: string; + pkApiKey: string; + organizationId: string; + projectId: string; + organization: { id: string; slug: string; title: string }; + project: { id: string; slug: string; name: string; externalRef: string }; + } +): RbacEnvironment { + return { + id: env.id, + slug: env.slug, + type: env.type, + apiKey: env.apiKey, + pkApiKey: env.pkApiKey, + organizationId: env.organizationId, + projectId: env.projectId, + organization: { + id: env.organization.id, + slug: env.organization.slug, + title: env.organization.title, + }, + project: { + id: env.project.id, + slug: env.project.slug, + name: env.project.name, + externalRef: env.project.externalRef, + }, + }; +} + +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..3592d8c48e1 --- /dev/null +++ b/internal-packages/rbac/src/index.ts @@ -0,0 +1,241 @@ +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"; + +type RbacHelpers = { getSessionUserId: (request: Request) => Promise }; + +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: PrismaClient, helpers: RbacHelpers, options?: RbacCreateOptions) { + this._init = this.load(prisma, helpers, options); + } + + private async load( + prisma: PrismaClient, + helpers: RbacHelpers, + options?: RbacCreateOptions + ): Promise { + if (options?.forceFallback) { + return new RoleBaseAccessFallback(prisma).create(helpers); + } + 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(helpers); + } 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(helpers); + } + } + + 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 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 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: PrismaClient, + helpers: RbacHelpers, + options?: RbacCreateOptions + ): RoleBaseAccessController { + return new LazyController(prisma, helpers, 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/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index b3f69f77d0a..6757ab64e6c 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -7,7 +7,11 @@ 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"; +// `expect` is only used inside assertNonNullable — lazy-loaded via require +// inside the function so this module can be imported in non-test contexts +// (e.g. a vitest globalSetup that starts containers before any worker +// exists, where vitest's expect-init-at-load-time would crash). +import type { TaskContext } from "vitest"; import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse"; import { MinIOContainer } from "./minio"; import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs"; @@ -186,6 +190,10 @@ export async function createMinIOContainer(network: StartedNetwork) { } export function assertNonNullable(value: T): asserts value is NonNullable { + // Loaded lazily so importers of this module don't pay the vitest top-level + // init cost outside a test worker. See the import note at the top. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { expect } = require("vitest") as typeof import("vitest"); expect(value).toBeDefined(); expect(value).not.toBeNull(); } 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/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 00000000000..924abd1d01f --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,43 @@ +{ + "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" + ], + "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..76adec6886e --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,15 @@ +export type { + RoleBasedAccessControlPlugin, + RoleBaseAccessController, + RoleAssignmentResult, + RoleMutationResult, + Permission, + Role, + RbacAbility, + RbacSubject, + RbacResource, + RbacEnvironment, + RbacUser, + BearerAuthResult, + SessionAuthResult, +} from "./rbac.js"; diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts new file mode 100644 index 00000000000..c052b70d71f --- /dev/null +++ b/packages/plugins/src/rbac.ts @@ -0,0 +1,216 @@ +/** + * 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; + // 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; +}; + +export type RbacEnvironment = { + id: string; + slug: string; + type: string; + apiKey: string; + pkApiKey: string; + organizationId: string; + projectId: string; + organization: { id: string; slug: string; title: string }; + project: { id: string; slug: string; name: string; externalRef: string }; +}; + +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: RbacEnvironment; + 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 }; + +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 + authenticateSession( + 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: { 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; + + 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( + helpers: { getSessionUserId: (request: Request) => Promise } + ): 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..6df7fca54bd 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 @@ -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,21 @@ importers: specifier: 4.17.0 version: 4.17.0 + packages/plugins: + 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 +19048,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'} @@ -38484,6 +38517,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) @@ -41156,11 +41198,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 +41377,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 +41387,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)