feat(webapp): consolidate auth path + add comprehensive auth tests (#3499)
## Summary
Consolidates the webapp's authentication and authorization into a small
set of route helpers, replacing the ad-hoc `requireUser` /
`requireUserId` / `authenticatedEnvironmentForAuthentication` calls
scattered across routes. Same security model, but the per-request flow
(authenticate → authorize → load) now lives in one place per route
family.
Introduces a plugin seam (`@trigger.dev/plugins`) that lets the cloud
build install a richer RBAC implementation without touching webapp code.
The OSS fallback keeps the pre-RBAC permissive behaviour intact, so
self-hosted deployments work unchanged.
Adds a comprehensive end-to-end auth test suite that didn't exist before
— 193 `it()` blocks (vitest reports ~199 after `it.each` expansion)
covering API key, PAT and JWT auth across the public API surface, plus
dashboard session auth for admin pages.
## Changes
### Plugin contract — `@trigger.dev/plugins`
`RoleBaseAccessController` interface authoritative for both OSS
(fallback) and cloud (enterprise plugin):
- `authenticateBearer(request, { allowJWT? })` — API-key / public-JWT
auth, returns env + ability
- `authenticateSession(request, { userId, organizationId?, projectId?
})` — dashboard auth, caller resolves `userId` from the session cookie
and passes it in (no `helpers.getSessionUserId` callback — decouples the
plugin host from session-cookie code)
- `authenticatePat(request, { organizationId?, projectId? })` — PAT
auth, returns identity + `lastAccessedAt` so the host can throttle the
per-request update
- `authenticateAuthorize*` variants for the auth-and-check-in-one-call
cases
- `isUsingPlugin(): Promise<boolean>` — capability flag for UI /
branching where plugin-present-ness matters; replaces the
sentinel-string coupling that had `personalAccessToken.server` matching
`"RBAC plugin not installed"` literally
### Dashboard auth (started, partial rollout)
Admin and settings pages migrated to a unified `dashboardLoader` /
`dashboardAction` helper that authenticates the session, runs an
authorization check, and exposes the result to the route. Other
dashboard routes still on the old pattern; remaining migration tracked
in TRI-8730.
Migrated routes:
- `admin.*` (14 admin / back-office / feature-flags / LLM-models /
notifications / orgs / concurrency pages)
- `_app.orgs.$organizationSlug.settings.team`
- `_app.orgs.$organizationSlug.settings.roles`
### API / realtime / engine auth (complete for the migrated families)
71 routes migrated to a unified `apiBuilder` that centralizes Bearer /
PAT / Public-JWT authentication and applies the per-route authorization
check before the handler runs. Includes:
- `api.v1.*` and `api.v2.*` and `api.v3.*` — tasks, runs, batches,
queues, prompts, deployments, query, sessions, waitpoints, packets,
workers, idempotency keys
- `realtime.v1.*` — runs, batches, sessions, streams
- `engine.v1.*` — dev / worker-action protocols
29 routes still on the legacy `authenticateApiRequest*` helpers —
tracked as a post-deploy follow-up in TRI-9228.
Multi-resource auth direction is now explicit at the call site via
`anyResource(...)` (OR) and `everyResource(...)` (AND). Bare arrays no
longer typecheck — fixes a class of bug where a JWT scoped to one
resource could implicitly access others under OR semantics.
PAT auth path consolidated: was three DB queries per request (legacy
`authenticateApiRequestWithPersonalAccessToken` findFirst +
`rbac.authenticatePat` join + `lastAccessedAt` update). Now one query in
the steady state — plugin returns `lastAccessedAt`, host smart-skips the
update via JS-side throttle when fresh.
Side effect: action aliases preserved historic JWT scope semantics where
the new model is stricter (e.g. a `write:tasks` JWT now also satisfies
`trigger` / `batchTrigger` / `update` actions on the same resource —
matched at the auth boundary, not in the route handler).
### Backwards-compat fixes
The strict-match model regressed several real-world JWT shapes. Each
preserved via explicit `anyResource(...)` entries in the route's authz
block:
- **Batch retrieve routes** (`api.v1.batches.$batchId`, `api.v2.*`,
`realtime.v1.batches.*`) accept `read:runs` JWTs again (pre-RBAC
literal-match superScope behaviour)
- **Runs list routes** (`api.v1.runs`, `realtime.v1.runs`) accept
type-level `read:tasks` / `read:tags` on unfiltered queries (matched the
legacy `Object.keys` iteration semantic)
- **PAT/OAT auth shape** normalized through `toAuthenticated` so all
auth methods return the same slim `AuthenticatedEnvironment` (was:
API-key returned the slim shape but PAT/OAT returned raw Prisma
`Decimal` / no `orgMember`)
- **Scope `:` preservation** in resource ids — `read:tags:env:staging`
now correctly identifies the tag id as `env:staging`, not `env`
### Slim `AuthenticatedEnvironment`
Extracted to `@trigger.dev/core/v3/auth/environment` — a structural
shape independent of `@trigger.dev/database`. The plugin contract
returns this; webapp consumers import from there; the cloud plugin
(Drizzle) returns the same shape without Prisma's `Decimal` class
leaking into the public surface. Lets internal-packages (run-engine,
etc.) refer to `AuthenticatedEnvironment` without pulling Prisma in.
### Auth test suite (new — `*.e2e.full.test.ts`)
193 e2e tests run against a real spawned webapp + Postgres (no mocks).
Coverage matrix:
- **API key auth** — read / write / trigger / batchTrigger / deploy
actions across runs, batches, deployments, prompts, queues, query,
sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key
resources (a run carries batch / tag / task identifiers — auth must
accept any matching scope)
- **Personal Access Token auth** — comprehensive matrix: scope match,
scope mismatch, missing scope, expired token, malformed token
- **Public JWT auth** — sub-vs-URL environment resolution, expired JWTs,
signature verification, scope checking, otu (one-time-use) token
semantics, branch-environment signing-key fallback
- **Dashboard session auth** — admin-only pages reject non-admins;
per-action gating
- **Cross-cutting edge cases** — revoked API key grace window, JWT
cross-environment isolation, MissingResource branch behaviour
### Hygiene cleanups
- Deleted dead `app/services/authorization.server.ts` (legacy
`checkAuthorization` + types — no live consumers post-migration) and its
orphaned test
- Dropped the never-populated `scopes` field from
`ApiAuthenticationResultSuccess`
- `scheduleEmail` moved out of `email.server.ts` into its own module —
breaks a `commonWorker → marqs/V1` import chain that was poisoning the
auth test graph
- OSS Roles page shows a deployment-aware empty state ("Roles aren't
available in this self-hosted deployment" vs the plan-upsell copy) via
`rbac.isUsingPlugin()`
- Team action handler: explicit per-intent ability gates
(`manage:billing` for purchase-seats, `manage:members` for set-role +
remove-member with self-leave carve-out)
### Cross-repo coordination
All public-package contract changes paired in `triggerdotdev/cloud#763`
(rbac-packages branch) — the enterprise plugin implements the same
`RoleBaseAccessController` interface against Drizzle.
## Test plan
- [x] `pnpm run typecheck --filter webapp` clean
- [x] `pnpm --filter webapp exec vitest run --config
vitest.e2e.full.config.ts` — 193/193 pass (requires Docker for
testcontainers)
- [x] Spot-check an authed API endpoint with a valid + invalid API key
against a local stack
- [x] Spot-check the migrated admin pages render and gate non-admins
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/plugins": patch
|
||||
---
|
||||
|
||||
The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces.
|
||||
@@ -0,0 +1,120 @@
|
||||
name: "🛡️ E2E Tests: Webapp Auth (full)"
|
||||
|
||||
# Comprehensive RBAC auth test suite — see TRI-8731. Runs separately from
|
||||
# the smoke e2e-webapp.yml because it covers every route family with a
|
||||
# pass/fail matrix and would otherwise dominate per-PR CI time.
|
||||
#
|
||||
# Triggered:
|
||||
# - Manually via workflow_dispatch.
|
||||
# - Nightly via schedule.
|
||||
# - On pull requests touching auth-relevant files only (paths filter).
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # 04:00 UTC daily
|
||||
pull_request:
|
||||
paths:
|
||||
- "apps/webapp/app/services/routeBuilders/**"
|
||||
- "apps/webapp/app/services/rbac.server.ts"
|
||||
- "apps/webapp/app/services/apiAuth.server.ts"
|
||||
- "apps/webapp/app/services/personalAccessToken.server.ts"
|
||||
- "apps/webapp/app/services/sessionStorage.server.ts"
|
||||
- "apps/webapp/app/routes/api.v*.**"
|
||||
- "apps/webapp/app/routes/realtime.v*.**"
|
||||
- "apps/webapp/test/**/*.e2e.full.test.ts"
|
||||
- "apps/webapp/test/setup/global-e2e-full-setup.ts"
|
||||
- "apps/webapp/test/helpers/sharedTestServer.ts"
|
||||
- "apps/webapp/test/helpers/seedTestSession.ts"
|
||||
- "apps/webapp/vitest.e2e.full.config.ts"
|
||||
- "internal-packages/rbac/**"
|
||||
- "packages/plugins/**"
|
||||
- ".github/workflows/e2e-webapp-auth-full.yml"
|
||||
|
||||
jobs:
|
||||
e2eAuthFull:
|
||||
name: "🛡️ E2E Auth Tests (full)"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
steps:
|
||||
- name: 🔧 Disable IPv6
|
||||
run: |
|
||||
sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
|
||||
sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
|
||||
sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
|
||||
|
||||
- name: 🔧 Configure docker address pool
|
||||
run: |
|
||||
CONFIG='{
|
||||
"default-address-pools" : [
|
||||
{
|
||||
"base" : "172.17.0.0/12",
|
||||
"size" : 20
|
||||
},
|
||||
{
|
||||
"base" : "192.168.0.0/16",
|
||||
"size" : 24
|
||||
}
|
||||
]
|
||||
}'
|
||||
mkdir -p /etc/docker
|
||||
echo "$CONFIG" | sudo tee /etc/docker/daemon.json
|
||||
|
||||
- name: 🔧 Restart docker daemon
|
||||
run: sudo systemctl restart docker
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Don't leave the GITHUB_TOKEN in .git/config — this job
|
||||
# doesn't need to push and the persisted creds would be
|
||||
# readable from any subsequent step (zizmor/artipacked).
|
||||
persist-credentials: false
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🐳 Login to DockerHub
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: 🐳 Skipping DockerHub login (no secrets available)
|
||||
if: ${{ !env.DOCKERHUB_USERNAME }}
|
||||
run: echo "DockerHub login skipped because secrets are not available."
|
||||
|
||||
- name: 🐳 Pre-pull testcontainer images
|
||||
if: ${{ env.DOCKERHUB_USERNAME }}
|
||||
run: |
|
||||
docker pull postgres:14
|
||||
docker pull redis:7.2
|
||||
docker pull testcontainers/ryuk:0.11.0
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🏗️ Build Webapp
|
||||
run: pnpm run build --filter webapp
|
||||
|
||||
- name: 🛡️ Run Webapp Full Auth E2E Tests
|
||||
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default
|
||||
env:
|
||||
WEBAPP_TEST_VERBOSE: "1"
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Webapp now supports a plugin system. Initially consolidates authentication and authorization paths.
|
||||
@@ -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,
|
||||
@@ -45,9 +47,11 @@ export type BuildInfo = {
|
||||
export function OrganizationSettingsSideMenu({
|
||||
organization,
|
||||
buildInfo,
|
||||
isUsingPlugin,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
buildInfo: BuildInfo;
|
||||
isUsingPlugin: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
@@ -128,6 +132,16 @@ export function OrganizationSettingsSideMenu({
|
||||
to={organizationTeamPath(organization)}
|
||||
data-action="team"
|
||||
/>
|
||||
{isUsingPlugin && (
|
||||
<SideMenuItem
|
||||
name="Roles"
|
||||
icon={ShieldCheckIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
inactiveIconColor="text-sky-500"
|
||||
to={organizationRolesPath(organization)}
|
||||
data-action="roles"
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Settings"
|
||||
icon={Cog8ToothIcon}
|
||||
|
||||
@@ -463,7 +463,15 @@ export function SelectItem({
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : 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 <Link>) get their
|
||||
// render prop silently dropped, which is why those rows looked
|
||||
// clickable but didn't navigate.
|
||||
const render = combobox
|
||||
? <Ariakit.ComboboxItem render={props.render} />
|
||||
: props.render;
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const select = Ariakit.useSelectContext();
|
||||
const selectValue = select?.useState("value");
|
||||
|
||||
@@ -1542,6 +1542,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)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { type Prisma, prisma } from "~/db.server";
|
||||
import { createEnvironment } from "./organization.server";
|
||||
import { customAlphabet } from "nanoid";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
|
||||
const tokenValueLength = 40;
|
||||
const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength);
|
||||
@@ -86,10 +88,19 @@ export async function inviteMembers({
|
||||
slug,
|
||||
emails,
|
||||
userId,
|
||||
rbacRoleId,
|
||||
}: {
|
||||
slug: string;
|
||||
emails: string[];
|
||||
userId: string;
|
||||
/**
|
||||
* Optional RBAC role to attach to the invite. When set, accepted
|
||||
* invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember
|
||||
* is created.
|
||||
*
|
||||
* `OrgMemberInvite.role` is still set if the plugin isn't installed.
|
||||
*/
|
||||
rbacRoleId?: string | null;
|
||||
}) {
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { slug, members: { some: { userId } } },
|
||||
@@ -107,6 +118,7 @@ export async function inviteMembers({
|
||||
organizationId: org.id,
|
||||
inviterId: userId,
|
||||
role: "MEMBER",
|
||||
rbacRoleId: rbacRoleId ?? null,
|
||||
} satisfies Prisma.OrgMemberInviteCreateManyInput)
|
||||
);
|
||||
|
||||
@@ -163,7 +175,7 @@ export async function acceptInvite({
|
||||
user: { id: string; email: string };
|
||||
inviteId: string;
|
||||
}) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1. Delete the invite and get the invite details
|
||||
const invite = await tx.orgMemberInvite.delete({
|
||||
where: {
|
||||
@@ -207,8 +219,32 @@ export async function acceptInvite({
|
||||
},
|
||||
});
|
||||
|
||||
return { remainingInvites, organization: invite.organization };
|
||||
return {
|
||||
remainingInvites,
|
||||
organization: invite.organization,
|
||||
inviteRole: invite.role,
|
||||
rbacRoleId: invite.rbacRoleId,
|
||||
};
|
||||
});
|
||||
|
||||
// If the invite carried an explicit RBAC role. Errors are logged, not fatal.
|
||||
if (result.rbacRoleId) {
|
||||
const roleResult = await rbac.setUserRole({
|
||||
userId: user.id,
|
||||
organizationId: result.organization.id,
|
||||
roleId: result.rbacRoleId,
|
||||
});
|
||||
if (!roleResult.ok) {
|
||||
logger.error("acceptInvite: skipped RBAC role assignment", {
|
||||
organizationId: result.organization.id,
|
||||
userId: user.id,
|
||||
rbacRoleId: result.rbacRoleId,
|
||||
reason: roleResult.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { remainingInvites: result.remainingInvites, organization: result.organization };
|
||||
}
|
||||
|
||||
export async function declineInvite({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,18 +3,100 @@ import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@tri
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { sanitizeBranchName } from "~/v3/gitBranch";
|
||||
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
|
||||
export type { RuntimeEnvironment };
|
||||
|
||||
// Prisma include shape that maps cleanly to the slim AuthenticatedEnvironment.
|
||||
// Use this everywhere we fetch an env that flows to handlers — keeps the
|
||||
// returned shape consistent (and the Decimal coercion in toAuthenticated()
|
||||
// strips Prisma's Decimal class from the public surface).
|
||||
export const authIncludeBase = {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
user: { select: { id: true, displayName: true, name: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.RuntimeEnvironmentInclude;
|
||||
|
||||
export const authIncludeWithParent = {
|
||||
...authIncludeBase,
|
||||
parentEnvironment: { select: { id: true, apiKey: true } },
|
||||
} satisfies Prisma.RuntimeEnvironmentInclude;
|
||||
|
||||
type PrismaEnvWithAuth = Prisma.RuntimeEnvironmentGetPayload<{ include: typeof authIncludeBase }>;
|
||||
type PrismaEnvWithAuthAndParent = Prisma.RuntimeEnvironmentGetPayload<{
|
||||
include: typeof authIncludeWithParent;
|
||||
}>;
|
||||
|
||||
// Coerce a Prisma RuntimeEnvironment payload to the slim
|
||||
// AuthenticatedEnvironment shape. Drops the columns handlers don't read
|
||||
// and converts `concurrencyLimitBurstFactor` from Prisma's Decimal to a
|
||||
// plain number (lossless at this scale). The optional union accepts both
|
||||
// query shapes — with parentEnvironment loaded, or without it.
|
||||
export function toAuthenticated(
|
||||
env: PrismaEnvWithAuth | PrismaEnvWithAuthAndParent,
|
||||
): AuthenticatedEnvironment {
|
||||
return {
|
||||
id: env.id,
|
||||
slug: env.slug,
|
||||
type: env.type,
|
||||
apiKey: env.apiKey,
|
||||
organizationId: env.organizationId,
|
||||
projectId: env.projectId,
|
||||
orgMemberId: env.orgMemberId,
|
||||
parentEnvironmentId: env.parentEnvironmentId,
|
||||
branchName: env.branchName,
|
||||
archivedAt: env.archivedAt,
|
||||
paused: env.paused,
|
||||
shortcode: env.shortcode,
|
||||
maximumConcurrencyLimit: env.maximumConcurrencyLimit,
|
||||
// Coerce Prisma's Decimal to a plain number — the slim type accepts
|
||||
// both, but downstream consumers shouldn't have to narrow before
|
||||
// doing arithmetic. Lossless at this scale (Decimal(4,2)).
|
||||
concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor.toNumber(),
|
||||
builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
project: {
|
||||
id: env.project.id,
|
||||
slug: env.project.slug,
|
||||
name: env.project.name,
|
||||
externalRef: env.project.externalRef,
|
||||
engine: env.project.engine,
|
||||
deletedAt: env.project.deletedAt,
|
||||
defaultWorkerGroupId: env.project.defaultWorkerGroupId,
|
||||
organizationId: env.project.organizationId,
|
||||
builderProjectId: env.project.builderProjectId,
|
||||
},
|
||||
organization: {
|
||||
id: env.organization.id,
|
||||
slug: env.organization.slug,
|
||||
title: env.organization.title,
|
||||
streamBasinName: env.organization.streamBasinName,
|
||||
maximumConcurrencyLimit: env.organization.maximumConcurrencyLimit,
|
||||
runsEnabled: env.organization.runsEnabled,
|
||||
maximumDevQueueSize: env.organization.maximumDevQueueSize,
|
||||
maximumDeployedQueueSize: env.organization.maximumDeployedQueueSize,
|
||||
featureFlags: env.organization.featureFlags,
|
||||
apiRateLimiterConfig: env.organization.apiRateLimiterConfig,
|
||||
batchRateLimitConfig: env.organization.batchRateLimitConfig,
|
||||
batchQueueConcurrencyConfig: env.organization.batchQueueConcurrencyConfig,
|
||||
},
|
||||
orgMember: env.orgMember,
|
||||
parentEnvironment: "parentEnvironment" in env ? env.parentEnvironment : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findEnvironmentByApiKey(
|
||||
apiKey: string,
|
||||
branchName: string | undefined
|
||||
): Promise<AuthenticatedEnvironment | null> {
|
||||
const include = {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
...authIncludeBase,
|
||||
childEnvironments: branchName
|
||||
? {
|
||||
where: {
|
||||
@@ -67,23 +149,33 @@ export async function findEnvironmentByApiKey(
|
||||
const childEnvironment = environment.childEnvironments.at(0);
|
||||
|
||||
if (childEnvironment) {
|
||||
return {
|
||||
return toAuthenticated({
|
||||
...childEnvironment,
|
||||
apiKey: environment.apiKey,
|
||||
orgMember: environment.orgMember,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
//A branch was specified but no child environment was found
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
/** @deprecated We don't use public api keys anymore */
|
||||
/**
|
||||
* @deprecated We don't use public API keys (`pk_*` tokens) anymore — public
|
||||
* access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`).
|
||||
*
|
||||
* Still exported because a handful of pre-RBAC routes that haven't been
|
||||
* migrated to the apiBuilder still wire this lookup into their
|
||||
* `authenticateApiKey` / `authenticateApiKeyWithFailure` flow. The new RBAC
|
||||
* fallback (`internal-packages/rbac/src/fallback.ts`) intentionally does NOT
|
||||
* call this — any pk_*-authenticated request that hits an apiBuilder route
|
||||
* returns 401. That's a deliberate cutover, not an oversight.
|
||||
*/
|
||||
export async function findEnvironmentByPublicApiKey(
|
||||
apiKey: string,
|
||||
branchName: string | undefined
|
||||
@@ -92,50 +184,29 @@ export async function findEnvironmentByPublicApiKey(
|
||||
where: {
|
||||
pkApiKey: apiKey,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
},
|
||||
include: authIncludeBase,
|
||||
});
|
||||
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
if (!environment || environment.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
export async function findEnvironmentById(
|
||||
id: string
|
||||
): Promise<
|
||||
| (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null })
|
||||
| null
|
||||
> {
|
||||
export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvironment | null> {
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
parentEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
apiKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: authIncludeWithParent,
|
||||
});
|
||||
|
||||
//don't return deleted projects
|
||||
if (environment?.project.deletedAt !== null) {
|
||||
if (!environment || environment.project.deletedAt !== null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return environment;
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
export async function findEnvironmentBySlug(
|
||||
@@ -143,7 +214,7 @@ export async function findEnvironmentBySlug(
|
||||
envSlug: string,
|
||||
userId: string
|
||||
): Promise<AuthenticatedEnvironment | null> {
|
||||
return $replica.runtimeEnvironment.findFirst({
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: envSlug,
|
||||
@@ -161,12 +232,9 @@ export async function findEnvironmentBySlug(
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
},
|
||||
include: authIncludeBase,
|
||||
});
|
||||
return environment ? toAuthenticated(environment) : null;
|
||||
}
|
||||
|
||||
export async function findEnvironmentFromRun(
|
||||
@@ -178,24 +246,16 @@ export async function findEnvironmentFromRun(
|
||||
id: runId,
|
||||
},
|
||||
include: {
|
||||
runtimeEnvironment: {
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
orgMember: true,
|
||||
},
|
||||
},
|
||||
runtimeEnvironment: { include: authIncludeBase },
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return taskRun?.runtimeEnvironment;
|
||||
return taskRun?.runtimeEnvironment ? toAuthenticated(taskRun.runtimeEnvironment) : null;
|
||||
}
|
||||
|
||||
export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) {
|
||||
export async function createNewSession(
|
||||
environment: Pick<RuntimeEnvironment, "id">,
|
||||
ipAddress: string
|
||||
) {
|
||||
const session = await prisma.runtimeEnvironmentSession.create({
|
||||
data: {
|
||||
environmentId: environment.id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTeamMembersAndInvites } from "~/models/member.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server";
|
||||
import { BasePresenter } from "./v3/basePresenter.server";
|
||||
|
||||
@@ -13,11 +14,30 @@ export class TeamPresenter extends BasePresenter {
|
||||
return;
|
||||
}
|
||||
|
||||
const [baseLimit, currentPlan, plans] = await Promise.all([
|
||||
getLimit(organizationId, "teamMembers", 100_000_000),
|
||||
getCurrentPlan(organizationId),
|
||||
getPlans(),
|
||||
]);
|
||||
const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] =
|
||||
await Promise.all([
|
||||
getLimit(organizationId, "teamMembers", 100_000_000),
|
||||
getCurrentPlan(organizationId),
|
||||
getPlans(),
|
||||
// RBAC role catalogue (system roles + any org-defined custom
|
||||
// roles). The default fallback returns []; an installed plugin
|
||||
// may return the seeded system roles plus any custom roles.
|
||||
rbac.allRoles(organizationId),
|
||||
// Plan-gated subset — the Teams page disables dropdown options not
|
||||
// in this set. Server-side enforcement is independent (setUserRole
|
||||
// rejects a plan-gated assignment regardless of UI state).
|
||||
rbac.getAssignableRoleIds(organizationId),
|
||||
// Per-member current role in a single round-trip.
|
||||
rbac.getUserRoles(
|
||||
result.members.map((m) => m.user.id),
|
||||
organizationId
|
||||
),
|
||||
]);
|
||||
|
||||
const memberRoles = result.members.map((m) => ({
|
||||
userId: m.user.id,
|
||||
role: memberRoleMap.get(m.user.id) ?? null,
|
||||
}));
|
||||
|
||||
const canPurchaseSeats =
|
||||
currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true;
|
||||
@@ -38,6 +58,9 @@ export class TeamPresenter extends BasePresenter {
|
||||
seatPricing,
|
||||
maxSeatQuota,
|
||||
planSeatLimit,
|
||||
roles,
|
||||
assignableRoleIds,
|
||||
memberRoles,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,10 +149,10 @@ type ApiRunListSearchParams = z.infer<typeof ApiRunListSearchParams>;
|
||||
|
||||
export class ApiRunListPresenter extends BasePresenter {
|
||||
public async call(
|
||||
project: Project,
|
||||
project: Pick<Project, "id">,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
apiVersion: API_VERSIONS,
|
||||
environment?: RuntimeEnvironment
|
||||
environment?: Pick<RuntimeEnvironment, "id" | "organizationId">
|
||||
) {
|
||||
return this.trace("call", async (span) => {
|
||||
const options: RunListOptions = {
|
||||
|
||||
@@ -47,7 +47,10 @@ export class EnvironmentQueuePresenter extends BasePresenter {
|
||||
running,
|
||||
queued,
|
||||
concurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
burstFactor: environment.concurrencyLimitBurstFactor.toNumber(),
|
||||
burstFactor:
|
||||
typeof environment.concurrencyLimitBurstFactor === "number"
|
||||
? environment.concurrencyLimitBurstFactor
|
||||
: environment.concurrencyLimitBurstFactor.toNumber(),
|
||||
runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled,
|
||||
queueSizeLimit,
|
||||
};
|
||||
|
||||
@@ -25,13 +25,15 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { inviteMembers } from "~/models/member.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
|
||||
import { scheduleEmail } from "~/services/email.server";
|
||||
import { scheduleEmail } from "~/services/scheduleEmail.server";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route";
|
||||
@@ -63,9 +65,77 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
// Inviter's own role drives the "below their level" filter on the
|
||||
// dropdown. Plus assignable role IDs already encode the org's plan
|
||||
// tier — the intersection is what we offer.
|
||||
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
|
||||
rbac.getUserRole({ userId, organizationId: organization.id }),
|
||||
rbac.getAssignableRoleIds(organization.id),
|
||||
rbac.systemRoles(organization.id),
|
||||
]);
|
||||
|
||||
// Build the dropdown's offerable set server-side: roles that are
|
||||
// (a) assignable on the current plan AND (b) at or below the
|
||||
// inviter's own level. The client just renders these — it doesn't
|
||||
// need to know about the system-role catalogue or the ladder.
|
||||
const assignableSet = new Set(assignableRoleIds);
|
||||
const offerableRoleIds = systemRoles
|
||||
? result.roles
|
||||
.filter(
|
||||
(r) =>
|
||||
assignableSet.has(r.id) &&
|
||||
isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id)
|
||||
)
|
||||
.map((r) => r.id)
|
||||
: [];
|
||||
|
||||
return typedjson({ ...result, offerableRoleIds });
|
||||
};
|
||||
|
||||
// Sentinel for "no RBAC role attached to invite" — the runtime
|
||||
// fallback will derive a role from the legacy OrgMember.role write at
|
||||
// accept time. Used when the org has no RBAC plugin installed (the
|
||||
// dropdown is hidden) or as a defensive default.
|
||||
const NO_RBAC_ROLE = "__no_rbac_role__";
|
||||
|
||||
// An inviter can only assign a role at or below their own. The
|
||||
// plugin's systemRoles array is in canonical order (highest authority
|
||||
// first), so array index drives the ladder — earlier index = higher
|
||||
// rank. Plan-tier filtering happens separately via assignableRoleIds;
|
||||
// the ladder is the absolute hierarchy. Custom roles aren't in the
|
||||
// table and are refused (TRI-8747's follow-up will handle them).
|
||||
type LadderRole = { id: string };
|
||||
|
||||
function buildRoleLevel(roles: ReadonlyArray<LadderRole>): Record<string, number> {
|
||||
const level: Record<string, number> = {};
|
||||
roles.forEach((r, i) => {
|
||||
// Top of the array = highest level. Subtract from length so larger
|
||||
// numbers always mean "more authority" — no off-by-one when a role
|
||||
// is added or removed.
|
||||
level[r.id] = roles.length - i;
|
||||
});
|
||||
return level;
|
||||
}
|
||||
|
||||
function isAtOrBelow(
|
||||
roles: ReadonlyArray<LadderRole>,
|
||||
inviterRoleId: string | null,
|
||||
invitedRoleId: string
|
||||
): boolean {
|
||||
// No RBAC role on inviter (e.g. the runtime fallback couldn't derive
|
||||
// one) → fall back to the legacy OrgMember.role check the calling
|
||||
// code already enforces. Allow the invite to proceed; the action
|
||||
// would have already failed earlier if the inviter wasn't allowed
|
||||
// to invite at all.
|
||||
if (!inviterRoleId) return true;
|
||||
const level = buildRoleLevel(roles);
|
||||
const inviter = level[inviterRoleId];
|
||||
const invited = level[invitedRoleId];
|
||||
// Custom roles aren't in the level table — refuse.
|
||||
if (inviter === undefined || invited === undefined) return false;
|
||||
return invited <= inviter;
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
emails: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
@@ -80,6 +150,7 @@ const schema = z.object({
|
||||
|
||||
return [""];
|
||||
}, z.string().email().array().nonempty("At least one email is required")),
|
||||
rbacRoleId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
@@ -94,11 +165,62 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
// Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown
|
||||
// role → don't pass one through; the runtime fallback handles it.
|
||||
// Validation: the chosen role must be in the org's assignable set
|
||||
// (plan-tier) and at or below the inviter's own level.
|
||||
let resolvedRbacRoleId: string | null = null;
|
||||
const submittedRbacRoleId = submission.value.rbacRoleId;
|
||||
if (
|
||||
submittedRbacRoleId &&
|
||||
submittedRbacRoleId !== NO_RBAC_ROLE
|
||||
) {
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug: organizationSlug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!org) {
|
||||
return json({ errors: { body: "Organization not found" } }, { status: 404 });
|
||||
}
|
||||
const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([
|
||||
rbac.getUserRole({ userId, organizationId: org.id }),
|
||||
rbac.getAssignableRoleIds(org.id),
|
||||
rbac.systemRoles(org.id),
|
||||
]);
|
||||
if (!systemRoles) {
|
||||
// No plugin installed but the form somehow submitted a role id —
|
||||
// ignore it (fall through to legacy behaviour rather than 400).
|
||||
resolvedRbacRoleId = null;
|
||||
} else {
|
||||
const assignable = new Set(assignableRoleIds);
|
||||
if (!assignable.has(submittedRbacRoleId)) {
|
||||
return json(
|
||||
{ errors: { body: "You can't invite someone with this role on your current plan" } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isAtOrBelow(
|
||||
systemRoles,
|
||||
inviterRole?.id ?? null,
|
||||
submittedRbacRoleId
|
||||
)
|
||||
) {
|
||||
return json(
|
||||
{ errors: { body: "You can only invite members at or below your own role" } },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
resolvedRbacRoleId = submittedRbacRoleId;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const invites = await inviteMembers({
|
||||
slug: organizationSlug,
|
||||
emails: submission.value.emails,
|
||||
userId,
|
||||
rbacRoleId: resolvedRbacRoleId,
|
||||
});
|
||||
|
||||
for (const invite of invites) {
|
||||
@@ -128,12 +250,35 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const {
|
||||
limits,
|
||||
canPurchaseSeats,
|
||||
seatPricing,
|
||||
extraSeats,
|
||||
maxSeatQuota,
|
||||
planSeatLimit,
|
||||
roles,
|
||||
offerableRoleIds,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
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() {
|
||||
</Fragment>
|
||||
))}
|
||||
</InputGroup>
|
||||
{showRolePicker ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor="rbacRoleId">Role</Label>
|
||||
<input type="hidden" name="rbacRoleId" value={selectedRoleId} />
|
||||
<Select<string, (typeof offerable)[number]>
|
||||
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) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
Invitees join with this role. They can be promoted later
|
||||
from the Team page.
|
||||
</Paragraph>
|
||||
</InputGroup>
|
||||
) : null}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"} disabled={total > limits.limit}>
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
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<string | null> {
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
return org?.id ?? null;
|
||||
}
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: Params,
|
||||
context: async (params) => {
|
||||
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return orgId ? { organizationId: orgId } : {};
|
||||
},
|
||||
authorization: { action: "read", resource: { type: "members" } },
|
||||
},
|
||||
async ({ context }) => {
|
||||
const orgId = context.organizationId;
|
||||
if (!orgId) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] =
|
||||
await Promise.all([
|
||||
rbac.allRoles(orgId),
|
||||
rbac.getAssignableRoleIds(orgId),
|
||||
rbac.allPermissions(orgId),
|
||||
rbac.systemRoles(orgId),
|
||||
// OSS self-host: no enterprise plugin → no role infrastructure to
|
||||
// show. Render a "roles aren't available" layout in that case
|
||||
// rather than the plan-upsell empty state (which assumes a cloud
|
||||
// plan and would be misleading).
|
||||
rbac.isUsingPlugin(),
|
||||
]);
|
||||
|
||||
return typedjson({
|
||||
roles,
|
||||
assignableRoleIds,
|
||||
allPermissions,
|
||||
systemRoles,
|
||||
isUsingPlugin,
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
type LoaderData = UseDataFunctionReturn<typeof loader>;
|
||||
type LoaderRole = LoaderData["roles"][number];
|
||||
type LoaderPermission = LoaderData["allPermissions"][number];
|
||||
type RolePermission = LoaderRole["permissions"][number];
|
||||
|
||||
// Permissions are bucketed by `permission.group` from the plugin.
|
||||
// Section order = first-seen order in `allPermissions()`. Permissions
|
||||
// without a group fall into "Other" at the bottom.
|
||||
const FALLBACK_GROUP = "Other";
|
||||
|
||||
export default function Page() {
|
||||
const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
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<string, LoaderRole>(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 (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Roles" />
|
||||
{/* Suppress the Enterprise-upsell button on OSS — there's no
|
||||
plan to upgrade to in a self-hosted deployment, and the
|
||||
dialog copy ("Available on the Enterprise plan") doesn't
|
||||
apply. The not-supported empty state below makes the
|
||||
absence of role infrastructure clear instead. */}
|
||||
{isUsingPlugin && !isEnterprise ? <CreateRoleUpsell /> : null}
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr]">
|
||||
<div className="border-b border-grid-bright px-4 py-6">
|
||||
<Paragraph variant="small">
|
||||
Roles control what each team member can do in <strong>{organization.title}</strong>.
|
||||
Compare what each role grants below; assign a role to a team member from the{" "}
|
||||
<TextLink to={`/orgs/${organization.slug}/settings/team`}>Team page</TextLink>.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{columns.length === 0 ? (
|
||||
<EmptyState isUsingPlugin={isUsingPlugin} />
|
||||
) : (
|
||||
<Table containerClassName="border-t-0">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Permission</TableHeaderCell>
|
||||
{columns.map(({ role }) => (
|
||||
<TableHeaderCell key={role.id}>
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{role.name}</span>
|
||||
<PlanBadge
|
||||
roleId={role.id}
|
||||
assignable={assignable}
|
||||
systemRoleIdSet={systemRoleIdSet}
|
||||
/>
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grouped.length === 0 ? (
|
||||
<TableBlankRow colSpan={columns.length + 2}>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
No permissions to display.
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
grouped.flatMap(({ group, permissions }) => [
|
||||
<TableRow key={`${group}-header`}>
|
||||
<TableCell colSpan={columns.length + 2} className="bg-charcoal-800">
|
||||
<Header3 className="text-xs uppercase tracking-wide text-text-dimmed">
|
||||
{group}
|
||||
</Header3>
|
||||
</TableCell>
|
||||
</TableRow>,
|
||||
...permissions.map((permission) => (
|
||||
<TableRow key={permission.name}>
|
||||
<TableCell>
|
||||
<code className="text-xs">{permission.name}</code>
|
||||
</TableCell>
|
||||
{columns.map(({ role }) => (
|
||||
<TableCell key={role.id}>
|
||||
<RoleCell
|
||||
permissionName={permission.name}
|
||||
rolePermissions={role.permissions}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
<Paragraph variant="small">
|
||||
{permission.description || (
|
||||
<span className="text-text-dimmed">—</span>
|
||||
)}
|
||||
</Paragraph>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)),
|
||||
])
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) {
|
||||
// Two distinct empty states:
|
||||
//
|
||||
// 1. Plugin loaded, but rbac.allRoles returned nothing the org can
|
||||
// use under its plan tier. The plan-upsell copy is correct —
|
||||
// upgrade unlocks the role infrastructure.
|
||||
// 2. No plugin loaded (OSS self-host). There's no "plan" to upgrade
|
||||
// to. RBAC simply isn't part of this deployment; we use a
|
||||
// permissive ability for every authenticated user and rely on
|
||||
// org-membership for access control. Surface that honestly
|
||||
// instead of dangling a fake upgrade carrot.
|
||||
if (!isUsingPlugin) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-8 text-center">
|
||||
<Header3>Roles aren't available in this self-hosted deployment.</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
All members have full access. Role-Based Access Controls are available in Trigger.dev
|
||||
Cloud or with an enterprise self-hosted license.
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-2 p-8 text-center">
|
||||
<Header3>No roles available on this plan.</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Upgrade to Pro to unlock RBAC.
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanBadge({
|
||||
roleId,
|
||||
assignable,
|
||||
systemRoleIdSet,
|
||||
}: {
|
||||
roleId: string;
|
||||
assignable: ReadonlySet<string>;
|
||||
systemRoleIdSet: ReadonlySet<string>;
|
||||
}) {
|
||||
// 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 <Badge variant="extra-small">Pro</Badge>;
|
||||
}
|
||||
return <Badge variant="extra-small">Enterprise</Badge>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<span className="text-text-dimmed" aria-label="Not granted">
|
||||
<XMarkIcon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span className="text-error" aria-label="Denied">
|
||||
<XMarkIcon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<span className="text-xs text-text-dimmed">{conditionLabel(conditionalDeny.conditions)}</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-success" aria-label="Allowed">
|
||||
<CheckIcon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 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, unknown>): string {
|
||||
if (typeof conditions.envType === "string") {
|
||||
if (conditions.envType === "PRODUCTION") return "Non-prod only";
|
||||
return `Non-${conditions.envType.toLowerCase()} only`;
|
||||
}
|
||||
return JSON.stringify(conditions);
|
||||
}
|
||||
|
||||
function groupPermissions(
|
||||
permissions: LoaderPermission[]
|
||||
): { group: string; permissions: LoaderPermission[] }[] {
|
||||
// Insertion-ordered map: groups appear in the order their first
|
||||
// permission was seen. Plugins that want a specific section order
|
||||
// just emit permissions in that order from `allPermissions()`.
|
||||
const buckets = new Map<string, LoaderPermission[]>();
|
||||
for (const permission of permissions) {
|
||||
const group = permission.group ?? FALLBACK_GROUP;
|
||||
const list = buckets.get(group) ?? [];
|
||||
list.push(permission);
|
||||
buckets.set(group, list);
|
||||
}
|
||||
return Array.from(buckets, ([group, permissions]) => ({ group, permissions }));
|
||||
}
|
||||
|
||||
function CreateRoleUpsell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary/small">Create role</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Custom roles are an Enterprise feature</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Paragraph>
|
||||
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.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Get in touch and we'll walk you through the Enterprise plan and how custom roles fit
|
||||
your team.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button variant="secondary/medium" onClick={() => setOpen(false)}>
|
||||
Maybe later
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
onClick={() => {
|
||||
window.open("https://trigger.dev/contact", "_blank");
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Contact us
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug },
|
||||
select: { id: true },
|
||||
});
|
||||
return org?.id ?? null;
|
||||
}
|
||||
|
||||
if (!organization) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
export const loader = dashboardLoader(
|
||||
{
|
||||
params: Params,
|
||||
context: async (params) => {
|
||||
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
|
||||
return orgId ? { organizationId: orgId } : {};
|
||||
},
|
||||
authorization: { action: "read", resource: { type: "members" } },
|
||||
},
|
||||
async ({ user, ability, context }) => {
|
||||
const orgId = context.organizationId;
|
||||
if (!orgId) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new TeamPresenter();
|
||||
const result = await presenter.call({
|
||||
userId: user.id,
|
||||
organizationId: orgId,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
// 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 presenter = new TeamPresenter();
|
||||
const result = await presenter.call({
|
||||
userId,
|
||||
organizationId: organization.id,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson(result);
|
||||
};
|
||||
);
|
||||
|
||||
const schema = z.object({
|
||||
memberId: z.string(),
|
||||
@@ -111,89 +134,157 @@ 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. Each branch inside checks the right ability:
|
||||
// set-role → manage:members
|
||||
// purchase-seats → manage:billing
|
||||
// remove-member → manage:members (skipped for self-leave)
|
||||
// Don't rely on the model-layer (removeTeamMember /
|
||||
// SetSeatsAddOnService) for enforcement — those are defense in
|
||||
// depth; the route layer is where the ability gate belongs.
|
||||
},
|
||||
async ({ user, ability, request, params, context }) => {
|
||||
const userId = user.id;
|
||||
const { organizationSlug } = params;
|
||||
invariant(organizationSlug, "organizationSlug not found");
|
||||
|
||||
if (formType === "purchase-seats") {
|
||||
const org = await $replica.organization.findFirst({
|
||||
where: { slug: organizationSlug },
|
||||
select: { id: true },
|
||||
});
|
||||
const formData = await request.formData();
|
||||
const formType = formData.get("_formType");
|
||||
|
||||
if (!org) {
|
||||
return json({ ok: false, error: "Organization not found" } as const);
|
||||
if (formType === "set-role") {
|
||||
if (!ability.can("manage", { type: "members" })) {
|
||||
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
|
||||
}
|
||||
const orgId = context.organizationId;
|
||||
if (!orgId) {
|
||||
return json({ ok: false, error: "Organization not found" } as const, { status: 404 });
|
||||
}
|
||||
const submission = parse(formData, { schema: SetRoleSchema });
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
const result = await rbac.setUserRole({
|
||||
userId: submission.value.userId,
|
||||
organizationId: orgId,
|
||||
roleId: submission.value.roleId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return json({ ok: false, error: result.error } as const, { status: 400 });
|
||||
}
|
||||
return json({ ok: true } as const);
|
||||
}
|
||||
|
||||
const submission = parse(formData, { schema: PurchaseSchema });
|
||||
if (formType === "purchase-seats") {
|
||||
// Adjusting seat count is a billing operation. Pre-RBAC the team
|
||||
// page's loader gated the entire route on Owner/Admin, so reaching
|
||||
// this action implied authority. Post-RBAC the loader requires
|
||||
// `read:members` (broader audience), so gate the seat purchase
|
||||
// explicitly here against the right ability rather than relying
|
||||
// on the SetSeatsAddOnService for enforcement at the model layer.
|
||||
if (!ability.can("manage", { type: "billing" })) {
|
||||
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
|
||||
}
|
||||
// Reuse the orgId the dashboardBuilder already resolved in the
|
||||
// context callback (single slug → orgId lookup per request,
|
||||
// regardless of whether the OSS fallback or cloud plugin
|
||||
// services the auth — the plugin takes `organizationId` as
|
||||
// input and doesn't re-resolve from a slug).
|
||||
const orgId = context.organizationId;
|
||||
if (!orgId) {
|
||||
return json({ ok: false, error: "Organization not found" } as const);
|
||||
}
|
||||
|
||||
const submission = parse(formData, { schema: PurchaseSchema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const service = new SetSeatsAddOnService();
|
||||
const [error, result] = await tryCatch(
|
||||
service.call({
|
||||
userId,
|
||||
organizationId: orgId,
|
||||
action: submission.value.action,
|
||||
amount: submission.value.amount,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.amount = [result.error];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return json({ ok: true } as const);
|
||||
}
|
||||
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
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,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
submission.error.amount = [error instanceof Error ? error.message : "Unknown error"];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.amount = [result.error];
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return json({ ok: true } as const);
|
||||
}
|
||||
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value || submission.intent !== "submit") {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
try {
|
||||
const deletedMember = await removeTeamMember({
|
||||
userId,
|
||||
memberId: submission.value.memberId,
|
||||
slug: organizationSlug,
|
||||
// Default intent: remove a member or leave the org. Self-leave (the
|
||||
// actor removing their own membership) is always allowed. Removing
|
||||
// another member requires `manage:members` — pre-RBAC the
|
||||
// `removeTeamMember` model fn only verified the actor was a member
|
||||
// of the target org, so any org member could remove any other
|
||||
// member by id; this gate fixes that latent permissions hole.
|
||||
const targetMember = await $replica.orgMember.findFirst({
|
||||
where: { id: submission.value.memberId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (deletedMember.userId === userId) {
|
||||
return redirectWithSuccessMessage("/", request, `You left the organization`);
|
||||
const isSelfLeave = targetMember?.userId === userId;
|
||||
if (!isSelfLeave && !ability.can("manage", { type: "members" })) {
|
||||
return json({ ok: false, error: "Unauthorized" } as const, { status: 403 });
|
||||
}
|
||||
|
||||
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<typeof loader>["members"][number];
|
||||
type Invite = UseDataFunctionReturn<typeof loader>["invites"][number];
|
||||
type Role = UseDataFunctionReturn<typeof loader>["roles"][number];
|
||||
|
||||
export default function Page() {
|
||||
const {
|
||||
@@ -205,7 +296,16 @@ export default function Page() {
|
||||
seatPricing,
|
||||
maxSeatQuota,
|
||||
planSeatLimit,
|
||||
roles,
|
||||
assignableRoleIds,
|
||||
memberRoles,
|
||||
canManageMembers,
|
||||
} = useTypedLoaderData<typeof loader>();
|
||||
// Build a userId → roleId map so the dropdown's defaultValue matches
|
||||
// each member's current assignment without re-querying.
|
||||
const memberRoleByUserId = new Map<string, string>(
|
||||
memberRoles.flatMap((m) => (m.role ? [[m.userId, m.role.id]] : []))
|
||||
);
|
||||
const user = useUser();
|
||||
const organization = useOrganization();
|
||||
|
||||
@@ -242,10 +342,31 @@ export default function Page() {
|
||||
))}
|
||||
</Property.Table>
|
||||
</AdminDebugTooltip>
|
||||
{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.
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<ButtonContent variant="primary/small" LeadingIcon={UserPlusIcon} className="cursor-not-allowed opacity-50">
|
||||
<ButtonContent
|
||||
variant="primary/small"
|
||||
LeadingIcon={UserPlusIcon}
|
||||
className="cursor-not-allowed opacity-50"
|
||||
>
|
||||
Invite a team member
|
||||
</ButtonContent>
|
||||
}
|
||||
content="You don't have permission to invite team members"
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : requiresUpgrade ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<ButtonContent
|
||||
variant="primary/small"
|
||||
LeadingIcon={UserPlusIcon}
|
||||
className="cursor-not-allowed opacity-50"
|
||||
>
|
||||
Invite a team member
|
||||
</ButtonContent>
|
||||
}
|
||||
@@ -291,34 +412,57 @@ export default function Page() {
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<Header2>Active team members</Header2>
|
||||
<ul className="divide-ui-border mb-8 mt-3 flex w-full flex-col divide-y border-y border-grid-bright">
|
||||
<div className="mt-4 flex items-baseline justify-between">
|
||||
<Header2>Active team members</Header2>
|
||||
{roles.length > 0 ? (
|
||||
<a
|
||||
className="text-xs text-text-link hover:underline"
|
||||
href={organizationRolesPath(organization)}
|
||||
>
|
||||
View all role permissions →
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-8 mt-3 grid w-full grid-cols-[1fr_auto_auto] items-center gap-x-2 border-y border-grid-bright">
|
||||
{members.map((member) => (
|
||||
<li key={member.user.id} className="flex items-center gap-x-4 py-4">
|
||||
<UserAvatar
|
||||
avatarUrl={member.user.avatarUrl}
|
||||
name={member.user.name}
|
||||
className="size-10"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Header3>
|
||||
{member.user.name}{" "}
|
||||
{member.user.id === user.id && (
|
||||
<span className="text-text-dimmed">(You)</span>
|
||||
)}
|
||||
</Header3>
|
||||
<Paragraph variant="small">{member.user.email}</Paragraph>
|
||||
<div
|
||||
key={member.user.id}
|
||||
className="col-span-3 grid grid-cols-subgrid items-center gap-x-2 border-b border-grid-bright py-2 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<UserAvatar
|
||||
avatarUrl={member.user.avatarUrl}
|
||||
name={member.user.name}
|
||||
className="size-10"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Header3>
|
||||
{member.user.name}{" "}
|
||||
{member.user.id === user.id && (
|
||||
<span className="text-text-dimmed">(You)</span>
|
||||
)}
|
||||
</Header3>
|
||||
<Paragraph variant="small">{member.user.email}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow items-center justify-end gap-4">
|
||||
<RolePicker
|
||||
memberUserId={member.user.id}
|
||||
currentRoleId={memberRoleByUserId.get(member.user.id) ?? null}
|
||||
roles={roles}
|
||||
assignableRoleIds={assignableRoleIds}
|
||||
canManageMembers={canManageMembers}
|
||||
/>
|
||||
<div className="justify-self-end">
|
||||
<LeaveRemoveButton
|
||||
userId={user.id}
|
||||
member={member}
|
||||
memberCount={members.length}
|
||||
canManageMembers={canManageMembers}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -387,10 +531,12 @@ function LeaveRemoveButton({
|
||||
userId,
|
||||
member,
|
||||
memberCount,
|
||||
canManageMembers,
|
||||
}: {
|
||||
userId: string;
|
||||
member: Member;
|
||||
memberCount: number;
|
||||
canManageMembers: boolean;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
|
||||
@@ -409,7 +555,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 (
|
||||
<LeaveTeamModal
|
||||
member={member}
|
||||
@@ -421,7 +568,20 @@ function LeaveRemoveButton({
|
||||
);
|
||||
}
|
||||
|
||||
//you remove another member
|
||||
//you remove another member — requires manage:members
|
||||
if (!canManageMembers) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<ButtonContent variant="secondary/small" className="cursor-not-allowed opacity-50">
|
||||
Remove from team
|
||||
</ButtonContent>
|
||||
}
|
||||
disableHoverableContent
|
||||
content="You don't have permission to remove team members"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LeaveTeamModal
|
||||
member={member}
|
||||
@@ -433,6 +593,100 @@ function LeaveRemoveButton({
|
||||
);
|
||||
}
|
||||
|
||||
// Inline role picker — submits a `_formType=set-role` form via fetcher
|
||||
// so the change persists without a full page reload. Disabled options
|
||||
// (and the picker itself) reflect plan gating + manage:members; the
|
||||
// server's setUserRole enforces both checks again as the source of
|
||||
// truth, so this is a UI-affordance layer only.
|
||||
function RolePicker({
|
||||
memberUserId,
|
||||
currentRoleId,
|
||||
roles,
|
||||
assignableRoleIds,
|
||||
canManageMembers,
|
||||
}: {
|
||||
memberUserId: string;
|
||||
currentRoleId: string | null;
|
||||
roles: Role[];
|
||||
assignableRoleIds: string[];
|
||||
canManageMembers: boolean;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const fetcher = useFetcher<{ ok: boolean; error?: string } | { ok: true }>();
|
||||
const assignable = new Set(assignableRoleIds);
|
||||
// With no RBAC plugin installed, the loader returns no roles —
|
||||
// render nothing rather than an empty dropdown.
|
||||
if (roles.length === 0) return null;
|
||||
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
const error =
|
||||
fetcher.data && "error" in fetcher.data && fetcher.data.error ? fetcher.data.error : null;
|
||||
|
||||
const picker = (
|
||||
<Select
|
||||
// Controlled — keeps the dropdown in sync with the persisted
|
||||
// role even after a failed set-role fetcher submit (the server
|
||||
// kept the old role; without `value` the UI would show the
|
||||
// attempted change).
|
||||
value={currentRoleId ?? ""}
|
||||
items={roles}
|
||||
variant="tertiary/small"
|
||||
disabled={!canManageMembers || isSubmitting}
|
||||
dropdownIcon
|
||||
text={(v) => roles.find((r) => r.id === v)?.name ?? "No role"}
|
||||
setValue={(next) => {
|
||||
if (typeof next !== "string" || next === (currentRoleId ?? "")) return;
|
||||
// Upgrade-link rows have a value too (Ariakit needs one to
|
||||
// make the row interactive — without it the Link inside
|
||||
// doesn't even register the click), but they shouldn't
|
||||
// submit the role-change form. The Link navigates the user
|
||||
// to the plan-selection page; we just bail here.
|
||||
if (!assignable.has(next)) return;
|
||||
fetcher.submit(
|
||||
{ _formType: "set-role", userId: memberUserId, roleId: next },
|
||||
{ method: "post" }
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((role) => {
|
||||
const isAssignable = assignable.has(role.id);
|
||||
return isAssignable ? (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</SelectItem>
|
||||
) : (
|
||||
<SelectLinkItem key={role.id} value={role.id} to={v3BillingPath(organization)}>
|
||||
{role.name} (upgrade)
|
||||
</SelectLinkItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
{canManageMembers ? (
|
||||
picker
|
||||
) : (
|
||||
// Disabled <Select> swallows hover events on its own, so wrap it
|
||||
// in a div the tooltip can attach to.
|
||||
<SimpleTooltip
|
||||
button={<div className="cursor-not-allowed">{picker}</div>}
|
||||
content="You don't have permission to change roles"
|
||||
disableHoverableContent
|
||||
/>
|
||||
)}
|
||||
{error ? (
|
||||
<span className="text-xs text-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LeaveTeamModal({
|
||||
member,
|
||||
buttonText,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
OrganizationSettingsSideMenu,
|
||||
} from "~/components/navigation/OrganizationSettingsSideMenu";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
return typedjson({
|
||||
@@ -18,17 +19,22 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
gitRefName: process.env.BUILD_GIT_REF_NAME,
|
||||
buildTimestampSeconds: process.env.BUILD_TIMESTAMP_SECONDS,
|
||||
} satisfies BuildInfo,
|
||||
isUsingPlugin: await rbac.isUsingPlugin(),
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { buildInfo } = useTypedLoaderData<typeof loader>();
|
||||
const { buildInfo, isUsingPlugin } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<AppContainer>
|
||||
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
|
||||
<OrganizationSettingsSideMenu organization={organization} buildInfo={buildInfo} />
|
||||
<OrganizationSettingsSideMenu
|
||||
organization={organization}
|
||||
buildInfo={buildInfo}
|
||||
isUsingPlugin={isUsingPlugin}
|
||||
/>
|
||||
<MainBody>
|
||||
<Outlet />
|
||||
</MainBody>
|
||||
|
||||
@@ -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,82 @@ 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.
|
||||
// Clamp to roles the picker actually renders (`roles` already
|
||||
// joins systemRoles ∩ assignableRoleIds). If userRoleId points at
|
||||
// a custom or plan-blocked role, the hidden form value would
|
||||
// otherwise post a roleId the action's revalidation rejects with
|
||||
// 400. Fall through to the most-restrictive assignable role.
|
||||
const assignableIds = new Set(roles.map((r) => r.id));
|
||||
const lowestAssignable = roles.at(-1)?.id ?? "";
|
||||
const defaultRoleId =
|
||||
userRoleId && assignableIds.has(userRoleId) ? userRoleId : lowestAssignable;
|
||||
|
||||
return typedjson({
|
||||
personalAccessTokens,
|
||||
roles,
|
||||
defaultRoleId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Response) {
|
||||
@@ -81,6 +153,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 +176,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 +225,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { personalAccessTokens } = useTypedLoaderData<typeof loader>();
|
||||
const { personalAccessTokens, roles, defaultRoleId } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -151,7 +245,7 @@ export default function Page() {
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>Create a Personal Access Token</DialogHeader>
|
||||
<CreatePersonalAccessToken />
|
||||
<CreatePersonalAccessToken roles={roles} defaultRoleId={defaultRoleId} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageAccessories>
|
||||
@@ -211,7 +305,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<typeof action>();
|
||||
const lastSubmission = fetcher.data as any;
|
||||
|
||||
@@ -228,6 +330,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 (
|
||||
<div className="max-w-full overflow-x-hidden">
|
||||
{token ? (
|
||||
@@ -248,6 +358,7 @@ function CreatePersonalAccessToken() {
|
||||
) : (
|
||||
<fetcher.Form method="post" {...form.props}>
|
||||
<input type="hidden" name="action" value="create" />
|
||||
{showRolePicker && <input type="hidden" name="roleId" value={selectedRoleId} />}
|
||||
<Fieldset className="mt-3">
|
||||
<InputGroup>
|
||||
<Label htmlFor={tokenName.id}>Name</Label>
|
||||
@@ -265,6 +376,37 @@ function CreatePersonalAccessToken() {
|
||||
<FormError id={tokenName.errorId}>{tokenName.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
{showRolePicker && (
|
||||
<InputGroup>
|
||||
<Label>Maximum role</Label>
|
||||
<Select<string, SystemRole>
|
||||
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) => (
|
||||
<SelectItem key={role.id} value={role.id}>
|
||||
<span className="flex flex-col">
|
||||
<span>{role.name}</span>
|
||||
{role.description ? (
|
||||
<span className="text-xs text-text-dimmed">{role.description}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
<Hint>
|
||||
The token can act with up to this role. Your current role in each org is the
|
||||
actual ceiling — the token never grants more than you have.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant={"primary/small"}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
@@ -17,8 +16,8 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { adminGetUsers } from "~/models/admin.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
|
||||
export const SearchParams = z.object({
|
||||
@@ -28,17 +27,34 @@ export const SearchParams = z.object({
|
||||
|
||||
export type SearchParams = z.infer<typeof SearchParams>;
|
||||
|
||||
export const loader = async ({ request }: 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 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);
|
||||
|
||||
return redirectWithImpersonation(request, id, "/");
|
||||
}
|
||||
);
|
||||
|
||||
export default function AdminDashboardRoute() {
|
||||
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
redirect,
|
||||
typedjson,
|
||||
@@ -36,59 +36,50 @@ import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { Header1 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { prisma } from "~/db.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
|
||||
const SAVED_QUERY_KEY = "saved";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
if (!user.admin) {
|
||||
return redirect("/");
|
||||
const ParamsSchema = z.object({
|
||||
orgId: z.string(),
|
||||
});
|
||||
|
||||
export const loader = dashboardLoader(
|
||||
{ authorization: { requireSuper: true }, params: ParamsSchema },
|
||||
async ({ params }) => {
|
||||
const orgId = params.orgId;
|
||||
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { id: orgId },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
apiRateLimiterConfig: true,
|
||||
batchRateLimitConfig: true,
|
||||
maximumProjectCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig);
|
||||
const batchEffective = resolveEffectiveBatchRateLimit(org.batchRateLimitConfig);
|
||||
|
||||
return typedjson({ org, apiEffective, batchEffective });
|
||||
}
|
||||
);
|
||||
|
||||
const orgId = params.orgId;
|
||||
if (!orgId) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
export const action = dashboardAction(
|
||||
{ authorization: { requireSuper: true }, params: ParamsSchema },
|
||||
async ({ user, params, request }) => {
|
||||
const orgId = params.orgId;
|
||||
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { id: orgId },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
apiRateLimiterConfig: true,
|
||||
batchRateLimitConfig: true,
|
||||
maximumProjectCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig);
|
||||
const batchEffective = resolveEffectiveBatchRateLimit(
|
||||
org.batchRateLimitConfig
|
||||
);
|
||||
|
||||
return typedjson({ org, apiEffective, batchEffective });
|
||||
}
|
||||
|
||||
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 intent = formData.get("intent");
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent");
|
||||
|
||||
if (intent === MAX_PROJECTS_INTENT) {
|
||||
const result = await handleMaxProjectsAction(formData, orgId, user.id);
|
||||
@@ -129,11 +120,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
return typedjson(
|
||||
{ section: null, errors: { intent: ["Unknown intent."] } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return typedjson(
|
||||
{ section: null, errors: { intent: ["Unknown intent."] } },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default function BackOfficeOrgPage() {
|
||||
const { org, apiEffective, batchEffective } =
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<typeof loader>();
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
|
||||
};
|
||||
|
||||
// Look up worker group name if the flag is set
|
||||
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
|
||||
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<string, string> = {
|
||||
[FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE,
|
||||
};
|
||||
|
||||
// Look up worker group name if the flag is set
|
||||
const workerGroupId = (globalFlags as Record<string, unknown>)?.[
|
||||
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 {
|
||||
|
||||
@@ -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 ParamsSchema = z.object({
|
||||
modelId: z.string(),
|
||||
});
|
||||
|
||||
const model = await prisma.llmModel.findUnique({
|
||||
where: { friendlyId: params.modelId },
|
||||
include: {
|
||||
pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } },
|
||||
},
|
||||
});
|
||||
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 });
|
||||
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) })),
|
||||
})),
|
||||
};
|
||||
// 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 });
|
||||
};
|
||||
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("/");
|
||||
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 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");
|
||||
|
||||
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 });
|
||||
if (_action === "delete") {
|
||||
await prisma.llmModel.delete({ where: { id: modelId } });
|
||||
await llmPricingRegistry?.reload();
|
||||
return redirect("/admin/llm-models");
|
||||
}
|
||||
|
||||
const { modelName, matchPattern, pricingTiersJson } = parsed.data;
|
||||
if (_action === "save") {
|
||||
const raw = Object.fromEntries(formData);
|
||||
const parsed = SaveSchema.safeParse(raw);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
if (!parsed.success) {
|
||||
return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse tiers
|
||||
let pricingTiers: Array<{
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
priority: number;
|
||||
conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
|
||||
prices: Record<string, number>;
|
||||
}>;
|
||||
try {
|
||||
pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers;
|
||||
} catch {
|
||||
return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 });
|
||||
}
|
||||
const { modelName, matchPattern, pricingTiersJson } = parsed.data;
|
||||
|
||||
// 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",
|
||||
},
|
||||
});
|
||||
// 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 });
|
||||
}
|
||||
|
||||
// Replace tiers
|
||||
await prisma.llmPricingTier.deleteMany({ where: { modelId } });
|
||||
for (const tier of pricingTiers) {
|
||||
await prisma.llmPricingTier.create({
|
||||
// Parse tiers
|
||||
let pricingTiers: Array<{
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
priority: number;
|
||||
conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>;
|
||||
prices: Record<string, number>;
|
||||
}>;
|
||||
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<typeof loader>();
|
||||
|
||||
@@ -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 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 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 }),
|
||||
]);
|
||||
|
||||
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`,
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
// Use the registry's match() which handles prefix stripping automatically
|
||||
const matched = llmPricingRegistry?.match(modelString) ?? null;
|
||||
// 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({
|
||||
testResult: {
|
||||
modelString,
|
||||
match: matched
|
||||
? { friendlyId: matched.friendlyId, modelName: matched.modelName }
|
||||
: null,
|
||||
},
|
||||
models,
|
||||
total,
|
||||
page,
|
||||
pageCount: Math.ceil(total / PAGE_SIZE),
|
||||
filters: { search },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
if (_action === "delete") {
|
||||
const modelId = formData.get("modelId");
|
||||
if (typeof modelId === "string") {
|
||||
await prisma.llmModel.delete({ where: { id: modelId } });
|
||||
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`,
|
||||
});
|
||||
}
|
||||
return typedjson({ success: true });
|
||||
}
|
||||
|
||||
return typedjson({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
return typedjson({ success: true });
|
||||
}
|
||||
|
||||
return typedjson({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
);
|
||||
|
||||
export default function AdminLlmModelsRoute() {
|
||||
const { models, filters, page, pageCount, total } =
|
||||
|
||||
@@ -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<typeof loader>();
|
||||
|
||||
@@ -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<ReturnType<typeof getMissingLlmModels>> = [];
|
||||
let error: string | undefined;
|
||||
|
||||
let models: Awaited<ReturnType<typeof getMissingLlmModels>> = [];
|
||||
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<typeof loader>();
|
||||
|
||||
@@ -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("/");
|
||||
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);
|
||||
|
||||
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<string, number>;
|
||||
}>;
|
||||
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<string, number>;
|
||||
}>;
|
||||
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[] }>();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { TrashIcon } 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, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
@@ -41,9 +39,8 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { prisma } from "~/db.server";
|
||||
import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
archivePlatformNotification,
|
||||
createPlatformNotification,
|
||||
@@ -68,55 +65,54 @@ 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 });
|
||||
};
|
||||
|
||||
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 === "create" || _action === "create-preview") {
|
||||
return handleCreateAction(formData, userId, _action === "create-preview");
|
||||
return typedjson({ ...data, userId: user.id });
|
||||
}
|
||||
);
|
||||
|
||||
if (_action === "archive") {
|
||||
return handleArchiveAction(formData);
|
||||
export const action = dashboardAction(
|
||||
{ authorization: { requireSuper: true } },
|
||||
async ({ user, request }) => {
|
||||
const userId = user.id;
|
||||
const formData = await request.formData();
|
||||
const _action = formData.get("_action");
|
||||
|
||||
if (_action === "create" || _action === "create-preview") {
|
||||
return handleCreateAction(formData, userId, _action === "create-preview");
|
||||
}
|
||||
|
||||
if (_action === "archive") {
|
||||
return handleArchiveAction(formData);
|
||||
}
|
||||
|
||||
if (_action === "delete") {
|
||||
return handleDeleteAction(formData);
|
||||
}
|
||||
|
||||
if (_action === "publish-now") {
|
||||
return handlePublishNowAction(formData);
|
||||
}
|
||||
|
||||
if (_action === "edit") {
|
||||
return handleEditAction(formData);
|
||||
}
|
||||
|
||||
return typedjson({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (_action === "delete") {
|
||||
return handleDeleteAction(formData);
|
||||
}
|
||||
|
||||
if (_action === "publish-now") {
|
||||
return handlePublishNowAction(formData);
|
||||
}
|
||||
|
||||
if (_action === "edit") {
|
||||
return handleEditAction(formData);
|
||||
}
|
||||
|
||||
return typedjson({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
);
|
||||
|
||||
function parseNotificationFormData(formData: FormData) {
|
||||
const surface = formData.get("surface") as string;
|
||||
|
||||
@@ -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<typeof SearchParams>;
|
||||
|
||||
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<typeof loader>();
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
@@ -25,8 +25,19 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (batch) => ({ batch: batch.friendlyId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
// Pre-RBAC, this route's `superScopes` included `read:runs`, so a
|
||||
// JWT minted with `read:runs` could read batches. The new strict
|
||||
// scope-type match means `read:runs` no longer trivially matches
|
||||
// `{type: "batch"}`. Include `{type: "runs"}` (alongside the
|
||||
// batch-id-scoped element) to preserve that semantic for any
|
||||
// SDK-issued tokens in the wild — a `read:runs` JWT still passes
|
||||
// batch retrieval. Per-id `read:batch:<id>` and type-level
|
||||
// `read:batch` still grant via the first element.
|
||||
resource: (batch) =>
|
||||
anyResource([
|
||||
{ type: "batch", id: batch.friendlyId },
|
||||
{ type: "runs" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
async ({ resource: batch }) => {
|
||||
|
||||
@@ -69,8 +69,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
|
||||
},
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import {
|
||||
ApiRunListPresenter,
|
||||
@@ -16,6 +17,20 @@ export const loader = createLoaderPATApiRoute(
|
||||
params: ParamsSchema,
|
||||
searchParams: ApiRunListSearchParams,
|
||||
corsStrategy: "all",
|
||||
// Resolve projectRef → org so the PAT plugin can ground its
|
||||
// role-floor calculation. We deliberately don't filter by user
|
||||
// membership here — that's the plugin's job (`authenticatePat`
|
||||
// checks OrgMember in the target org and rejects if the user
|
||||
// isn't a member). Keeps the contract clean: context is "what
|
||||
// org does this URL target?" and auth is "is this user allowed?"
|
||||
context: async (params) => {
|
||||
const project = await $replica.project.findFirst({
|
||||
where: { externalRef: params.projectRef },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
return project ? { organizationId: project.organizationId } : {};
|
||||
},
|
||||
authorization: { action: "read", resource: () => ({ type: "runs" }) },
|
||||
},
|
||||
async ({ searchParams, params, authentication, apiVersion }) => {
|
||||
const project = await findProjectByRef(params.projectRef, authentication.userId);
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { QueryError } from "@internal/clickhouse";
|
||||
import { z } from "zod";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
createActionApiRoute,
|
||||
everyResource,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { executeQuery, type QueryScope } from "~/services/queryService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { rowsToCSV } from "~/utils/dataExport";
|
||||
@@ -34,11 +37,16 @@ const { action, loader } = createActionApiRoute(
|
||||
findResource: async () => 1,
|
||||
authorization: {
|
||||
action: "read",
|
||||
// A multi-table query reads from every detected table. Wrap with
|
||||
// everyResource so a JWT scoped to one table can't pass auth for
|
||||
// a query that also reads tables it isn't scoped to (would be the
|
||||
// same OR-loophole the batch trigger route had pre-fix).
|
||||
resource: (_, __, ___, body) => {
|
||||
const tables = detectTables(body.query);
|
||||
return { query: tables.length > 0 ? tables : "all" };
|
||||
return tables.length > 0
|
||||
? everyResource(tables.map((id) => ({ type: "query", id })))
|
||||
: { type: "query", id: "all" };
|
||||
},
|
||||
superScopes: ["read:query", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ body, authentication }) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
|
||||
@@ -21,13 +24,17 @@ export const loader = createLoaderApiRoute(
|
||||
shouldRetryNotFound: true,
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batch?.friendlyId) {
|
||||
resources.push({ type: "batch", id: run.batch.friendlyId });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ resource: run, authentication }) => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { extractAISpanData } from "~/components/runs/v3/ai";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
|
||||
|
||||
@@ -28,13 +31,17 @@ export const loader = createLoaderApiRoute(
|
||||
shouldRetryNotFound: true,
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batchId) {
|
||||
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ params, resource: run, authentication }) => {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { json } from "@remix-run/server-runtime";
|
||||
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server";
|
||||
import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server";
|
||||
|
||||
@@ -26,13 +29,17 @@ export const loader = createLoaderApiRoute(
|
||||
shouldRetryNotFound: true,
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batchId) {
|
||||
resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ resource: run, authentication }) => {
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
ApiRunListSearchParams,
|
||||
} from "~/presenters/v3/ApiRunListPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
export const loader = createLoaderApiRoute(
|
||||
{
|
||||
@@ -13,8 +16,24 @@ 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]"] ?? [];
|
||||
// Pre-RBAC, the resource was `{ tasks: searchParams["filter[taskIdentifier]"] }`
|
||||
// and the legacy `checkAuthorization` iterated `Object.keys` — so a
|
||||
// JWT with type-level `read:tasks` (no id) granted access to the
|
||||
// unfiltered runs list. The new ability model only matches against
|
||||
// resources we list, so the type-level `{ type: "tasks" }` element
|
||||
// (alongside `{ type: "runs" }` and the per-id task elements)
|
||||
// preserves that semantic — `read:tasks` JWTs in the wild still
|
||||
// list unfiltered runs without needing a separate `read:runs`
|
||||
// scope. Per-id `read:tasks:foo` still grants only when the
|
||||
// filter includes `foo`.
|
||||
return anyResource([
|
||||
{ type: "runs" },
|
||||
{ type: "tasks" },
|
||||
...taskFilter.map((id) => ({ type: "tasks", id })),
|
||||
]);
|
||||
},
|
||||
},
|
||||
findResource: async () => 1, // This is a dummy function, we don't need to find a resource
|
||||
},
|
||||
|
||||
@@ -25,8 +25,7 @@ const { action, loader } = createActionApiRoute(
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "admin",
|
||||
resource: (params) => ({ sessions: params.session }),
|
||||
superScopes: ["admin:sessions", "admin:all", "admin"],
|
||||
resource: (params) => ({ type: "sessions", id: params.session }),
|
||||
},
|
||||
},
|
||||
async ({ authentication, params, body }) => {
|
||||
|
||||
@@ -8,7 +8,10 @@ import { $replica, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { swapSessionRun } from "~/services/realtime/sessionRunManager.server";
|
||||
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createActionApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
session: z.string(),
|
||||
@@ -42,15 +45,18 @@ const { action, loader } = createActionApiRoute(
|
||||
resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session),
|
||||
authorization: {
|
||||
action: "write",
|
||||
// Multi-key: the session is addressable by URL param, friendlyId,
|
||||
// and externalId — a JWT scoped to any of them grants access.
|
||||
// Type-level `write:sessions` (no id) also matches; `write:all` /
|
||||
// `admin` bypass via the JWT ability's wildcard branches.
|
||||
resource: (params, _, __, ___, session) => {
|
||||
const ids = new Set<string>([params.session]);
|
||||
if (session) {
|
||||
ids.add(session.friendlyId);
|
||||
if (session.externalId) ids.add(session.externalId);
|
||||
}
|
||||
return { sessions: [...ids] };
|
||||
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
|
||||
},
|
||||
superScopes: ["write:sessions", "write:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ authentication, params, body, resource: session }) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
serializeSessionWithFriendlyRunId,
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import {
|
||||
anyResource,
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
@@ -29,8 +30,17 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (session) => ({ sessions: [session.friendlyId, session.externalId ?? ""] }),
|
||||
superScopes: ["read:sessions", "read:all", "admin"],
|
||||
// Multi-key: a session is addressable by both friendlyId and (when
|
||||
// set) externalId. A JWT scoped to either id grants access; type-
|
||||
// level `read:sessions` (no id) matches both elements; `read:all`
|
||||
// / `admin` bypass via the JWT ability's wildcard branches.
|
||||
resource: (session) =>
|
||||
session.externalId
|
||||
? anyResource([
|
||||
{ type: "sessions", id: session.friendlyId },
|
||||
{ type: "sessions", id: session.externalId },
|
||||
])
|
||||
: { type: "sessions", id: session.friendlyId },
|
||||
},
|
||||
},
|
||||
async ({ resource: session }) => {
|
||||
@@ -50,8 +60,7 @@ const { action } = createActionApiRoute(
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "admin",
|
||||
resource: (params) => ({ sessions: params.session }),
|
||||
superScopes: ["admin:sessions", "admin:all", "admin"],
|
||||
resource: (params) => ({ type: "sessions", id: params.session }),
|
||||
},
|
||||
},
|
||||
async ({ authentication, params, body }) => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { serializeSession } from "~/services/realtime/sessions.server";
|
||||
import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import {
|
||||
anyResource,
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
@@ -37,8 +38,21 @@ export const loader = createLoaderApiRoute(
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }),
|
||||
superScopes: ["read:sessions", "read:all", "admin"],
|
||||
// Multi-key resource preserves the pre-RBAC superScope semantics:
|
||||
// - Per-task scoping via `read:tasks:<id>` matches a task element
|
||||
// - Type-level `read:sessions` (the old superScope) matches the
|
||||
// sessions element (collection-level — no id)
|
||||
// - `read:all` / `admin` bypass via the JWT ability's wildcard branches
|
||||
// The taskIdentifier filter accepts a string or an array; expand to
|
||||
// one resource per task id so any per-task-scoped JWT among them
|
||||
// grants access (the array gets OR semantics).
|
||||
resource: (_, __, searchParams) => {
|
||||
const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? [];
|
||||
return anyResource([
|
||||
...taskFilter.map((id) => ({ type: "tasks" as const, id })),
|
||||
{ type: "sessions" as const },
|
||||
]);
|
||||
},
|
||||
},
|
||||
findResource: async () => 1,
|
||||
},
|
||||
@@ -113,21 +127,20 @@ const { action } = createActionApiRoute(
|
||||
// Per-task scoping via `body.taskIdentifier` (action-route resource
|
||||
// callbacks receive the parsed body as the 4th arg — see
|
||||
// `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo`
|
||||
// can only create sessions whose `taskIdentifier` is `"foo"`. Broad
|
||||
// callers (cli-v3 MCP, customer servers wrapping their own auth)
|
||||
// hold the `write:sessions` super-scope and bypass the per-task
|
||||
// check entirely.
|
||||
// can only create sessions whose `taskIdentifier` is `"foo"`.
|
||||
//
|
||||
// Note: the auth check is OR across resource types, so listing both
|
||||
// `sessions` and `tasks` here would let a `write:sessions`-scoped
|
||||
// JWT pass for *any* task — defeating the per-task narrowing. Keep
|
||||
// it task-only and let the super-scope path handle session-level
|
||||
// wildcard access.
|
||||
// Multi-key resource: pre-RBAC this route had a `superScopes:
|
||||
// ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent
|
||||
// is the `{ type: "sessions" }` element below — a `write:sessions`
|
||||
// JWT (no id) matches it directly, deliberately bypassing the
|
||||
// per-task check exactly as before. `admin` / `write:all` bypass
|
||||
// via the JWT ability's wildcard branches.
|
||||
action: "write",
|
||||
resource: (_params, _searchParams, _headers, body) => ({
|
||||
tasks: body.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["write:sessions", "admin"],
|
||||
resource: (_params, _searchParams, _headers, body) =>
|
||||
anyResource([
|
||||
{ type: "tasks", id: body.taskIdentifier },
|
||||
{ type: "sessions" },
|
||||
]),
|
||||
},
|
||||
corsStrategy: "all",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -45,6 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
orgMember: true,
|
||||
parentEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
apiKey: true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
@@ -25,8 +25,13 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (batch) => ({ batch: batch.friendlyId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
// See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}`
|
||||
// preserves pre-RBAC `read:runs` superScope access for batch reads.
|
||||
resource: (batch) =>
|
||||
anyResource([
|
||||
{ type: "batch", id: batch.friendlyId },
|
||||
{ type: "runs" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
async ({ resource: batch }) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -18,13 +21,17 @@ export const loader = createLoaderApiRoute(
|
||||
shouldRetryNotFound: true,
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batch?.friendlyId) {
|
||||
resources.push({ type: "batch", id: run.batch.friendlyId });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ authentication, resource, apiVersion }) => {
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ import {
|
||||
WorkerApiRunAttemptStartRequestBody,
|
||||
WorkerApiRunAttemptStartResponseBody,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
||||
import { defaultMachine } from "~/services/platform.v3.server";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
@@ -76,7 +76,7 @@ const { action } = createActionApiRoute(
|
||||
);
|
||||
|
||||
async function getEnvVars(
|
||||
environment: RuntimeEnvironment,
|
||||
environment: AuthenticatedEnvironment,
|
||||
runId: string,
|
||||
machinePreset: MachinePreset,
|
||||
taskEventStore?: string
|
||||
|
||||
@@ -4,7 +4,7 @@ import { env } from "process";
|
||||
import { z } from "zod";
|
||||
import { resendInvite } from "~/models/member.server";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { scheduleEmail } from "~/services/email.server";
|
||||
import { scheduleEmail } from "~/services/scheduleEmail.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
batchId: z.string(),
|
||||
@@ -23,8 +23,13 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (batch) => ({ batch: batch.friendlyId }),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
// See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}`
|
||||
// preserves pre-RBAC `read:runs` superScope access for batch reads.
|
||||
resource: (batch) =>
|
||||
anyResource([
|
||||
{ type: "batch", id: batch.friendlyId },
|
||||
{ type: "runs" },
|
||||
]),
|
||||
},
|
||||
},
|
||||
async ({ authentication, request, resource: batchRun, apiVersion }) => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
@@ -31,13 +34,17 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batch?.friendlyId) {
|
||||
resources.push({ type: "batch", id: run.batch.friendlyId });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ authentication, request, resource: run, apiVersion }) => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { z } from "zod";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { realtimeClient } from "~/services/realtimeClientGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
|
||||
const SearchParamsSchema = z.object({
|
||||
tags: z
|
||||
@@ -21,8 +24,18 @@ 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) =>
|
||||
// Pre-RBAC, the resource was the searchParams object itself and
|
||||
// the legacy `checkAuthorization` iterated `Object.keys`, so a
|
||||
// JWT with type-level `read:tags` (no id) granted access to the
|
||||
// unfiltered runs stream. Including `{ type: "tags" }` here
|
||||
// preserves that — per-id `read:tags:<tag>` still grants only
|
||||
// when the filter includes that tag.
|
||||
anyResource([
|
||||
{ type: "runs" },
|
||||
{ type: "tags" },
|
||||
...(searchParams.tags ?? []).map((tag) => ({ type: "tags", id: tag })),
|
||||
]),
|
||||
},
|
||||
},
|
||||
async ({ searchParams, authentication, request, apiVersion }) => {
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
|
||||
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createActionApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
@@ -49,15 +52,16 @@ const { action, loader } = createActionApiRoute(
|
||||
action: "write",
|
||||
// Authorize against the union of the URL form, friendlyId, and
|
||||
// externalId so a JWT scoped to any form authorizes any URL.
|
||||
// Type-level `write:sessions` (no id) also matches; `write:all` /
|
||||
// `admin` bypass via the JWT ability's wildcard branches.
|
||||
resource: (params, _, __, ___, session) => {
|
||||
const ids = new Set<string>([params.session]);
|
||||
if (session) {
|
||||
ids.add(session.friendlyId);
|
||||
if (session.externalId) ids.add(session.externalId);
|
||||
}
|
||||
return { sessions: [...ids] };
|
||||
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
|
||||
},
|
||||
superScopes: ["write:sessions", "write:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ request, params, authentication, resource: session }) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import {
|
||||
anyResource,
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
@@ -30,8 +31,7 @@ const { action } = createActionApiRoute(
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: (params) => ({ sessions: params.session }),
|
||||
superScopes: ["write:sessions", "write:all", "admin"],
|
||||
resource: (params) => ({ type: "sessions", id: params.session }),
|
||||
},
|
||||
},
|
||||
async ({ params, authentication }) => {
|
||||
@@ -116,15 +116,18 @@ const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
// Multi-key: the channel is addressable by the URL key, the row's
|
||||
// friendlyId, and (if set) externalId. Type-level `read:sessions`
|
||||
// matches any of them; `read:all` / `admin` bypass via the JWT
|
||||
// ability's wildcard branches.
|
||||
resource: ({ row, addressingKey }) => {
|
||||
const ids = new Set<string>([addressingKey]);
|
||||
if (row) {
|
||||
ids.add(row.friendlyId);
|
||||
if (row.externalId) ids.add(row.externalId);
|
||||
}
|
||||
return { sessions: [...ids] };
|
||||
return anyResource([...ids].map((id) => ({ type: "sessions", id })));
|
||||
},
|
||||
superScopes: ["read:sessions", "read:all", "admin"],
|
||||
},
|
||||
},
|
||||
async ({ params, request, authentication, resource }) => {
|
||||
|
||||
@@ -3,7 +3,10 @@ import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
|
||||
import {
|
||||
anyResource,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
@@ -89,7 +92,13 @@ export const loader = createLoaderApiRoute(
|
||||
friendlyId: params.runId,
|
||||
runtimeEnvironmentId: auth.environment.id,
|
||||
},
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
taskIdentifier: true,
|
||||
runTags: true,
|
||||
realtimeStreamsVersion: true,
|
||||
streamBasinName: true,
|
||||
batch: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
@@ -100,13 +109,17 @@ export const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batch?.friendlyId) {
|
||||
resources.push({ type: "batch", id: run.batch.friendlyId });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
deleteInputStreamWaitpoint,
|
||||
} from "~/services/inputStreamWaitpointCache.server";
|
||||
import {
|
||||
anyResource,
|
||||
createActionApiRoute,
|
||||
createLoaderApiRoute,
|
||||
} from "~/services/routeBuilders/apiBuilder.server";
|
||||
@@ -31,8 +32,7 @@ const { action } = createActionApiRoute(
|
||||
corsStrategy: "all",
|
||||
authorization: {
|
||||
action: "write",
|
||||
resource: (params) => ({ inputStreams: params.runId }),
|
||||
superScopes: ["write:inputStreams", "write:all", "admin"],
|
||||
resource: (params) => ({ type: "inputStreams", id: params.runId }),
|
||||
},
|
||||
},
|
||||
async ({ request, params, authentication }) => {
|
||||
@@ -127,13 +127,17 @@ const loader = createLoaderApiRoute(
|
||||
},
|
||||
authorization: {
|
||||
action: "read",
|
||||
resource: (run) => ({
|
||||
runs: run.friendlyId,
|
||||
tags: run.runTags,
|
||||
batch: run.batch?.friendlyId,
|
||||
tasks: run.taskIdentifier,
|
||||
}),
|
||||
superScopes: ["read:runs", "read:all", "admin"],
|
||||
resource: (run) => {
|
||||
const resources = [
|
||||
{ type: "runs", id: run.friendlyId },
|
||||
{ type: "tasks", id: run.taskIdentifier },
|
||||
...run.runTags.map((tag) => ({ type: "tags", id: tag })),
|
||||
];
|
||||
if (run.batch?.friendlyId) {
|
||||
resources.push({ type: "batch", id: run.batch.friendlyId });
|
||||
}
|
||||
return anyResource(resources);
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ params, request, resource: run, authentication }) => {
|
||||
|
||||
@@ -32,7 +32,16 @@ function createBatchLimitsRedisClient() {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
function createOrganizationRateLimiter(organization: Organization): RateLimiter {
|
||||
// Just the org fields this module reads. Compatible with both the full
|
||||
// Prisma `Organization` payload and the slim `AuthenticatedEnvironment`
|
||||
// `["organization"]` shape (when passed `batchRateLimitConfig` /
|
||||
// `batchQueueConcurrencyConfig` as `unknown`).
|
||||
type OrganizationForBatchLimits = {
|
||||
batchRateLimitConfig?: unknown;
|
||||
batchQueueConcurrencyConfig?: unknown;
|
||||
};
|
||||
|
||||
function createOrganizationRateLimiter(organization: OrganizationForBatchLimits): RateLimiter {
|
||||
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
|
||||
|
||||
const limiter = createLimiterFromConfig(limiterConfig);
|
||||
@@ -72,7 +81,7 @@ function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimite
|
||||
* Internally looks up the plan type, but doesn't expose it to callers.
|
||||
*/
|
||||
export async function getBatchLimits(
|
||||
organization: Organization
|
||||
organization: OrganizationForBatchLimits
|
||||
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
|
||||
const rateLimiter = createOrganizationRateLimiter(organization);
|
||||
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { type Prettify } from "@trigger.dev/core";
|
||||
import { SignJWT, errors, jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -7,8 +6,11 @@ import { $replica } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import {
|
||||
authIncludeBase,
|
||||
authIncludeWithParent,
|
||||
findEnvironmentByApiKey,
|
||||
findEnvironmentByPublicApiKey,
|
||||
toAuthenticated,
|
||||
} from "~/models/runtimeEnvironment.server";
|
||||
import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import { logger } from "./logger.server";
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
isOrganizationAccessToken,
|
||||
} from "./organizationAccessToken.server";
|
||||
import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server";
|
||||
import { sanitizeBranchName } from "~/v3/gitBranch";
|
||||
import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
|
||||
const ClaimsSchema = z.object({
|
||||
scopes: z.array(z.string()).optional(),
|
||||
@@ -36,12 +38,10 @@ const ClaimsSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
type Optional<T, K extends keyof T> = Prettify<Omit<T, K> & Partial<Pick<T, K>>>;
|
||||
|
||||
export type AuthenticatedEnvironment = Optional<
|
||||
NonNullable<Awaited<ReturnType<typeof findEnvironmentByApiKey>>>,
|
||||
"orgMember"
|
||||
>;
|
||||
// Re-export the slim shape defined in @trigger.dev/core. Single source of
|
||||
// truth across the auth boundary (RBAC plugin contract → webapp handlers).
|
||||
export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
||||
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
||||
|
||||
export type ApiAuthenticationResult =
|
||||
| ApiAuthenticationResultSuccess
|
||||
@@ -52,7 +52,6 @@ export type ApiAuthenticationResultSuccess = {
|
||||
apiKey: string;
|
||||
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
||||
environment: AuthenticatedEnvironment;
|
||||
scopes?: string[];
|
||||
oneTimeUse?: boolean;
|
||||
realtime?: {
|
||||
skipColumns?: string[];
|
||||
@@ -163,7 +162,6 @@ export async function authenticateApiKey(
|
||||
ok: true,
|
||||
...result,
|
||||
environment: validationResults.environment,
|
||||
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
|
||||
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
|
||||
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
|
||||
};
|
||||
@@ -246,7 +244,6 @@ async function authenticateApiKeyWithFailure(
|
||||
ok: true,
|
||||
...result,
|
||||
environment: validationResults.environment,
|
||||
scopes: parsedClaims.success ? parsedClaims.data.scopes : [],
|
||||
oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false,
|
||||
realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined,
|
||||
};
|
||||
@@ -510,17 +507,14 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
include: authIncludeBase,
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return environment;
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
@@ -530,11 +524,7 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
branchName: sanitizedBranch,
|
||||
archivedAt: null,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
parentEnvironment: true,
|
||||
},
|
||||
include: authIncludeWithParent,
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
@@ -545,12 +535,13 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
|
||||
}
|
||||
|
||||
return {
|
||||
// PREVIEW envs reuse the parent's apiKey for downstream auth flows
|
||||
// (signed JWTs, internal-fetch helpers). Override before mapping so
|
||||
// the slim shape carries the parent's key.
|
||||
return toAuthenticated({
|
||||
...environment,
|
||||
apiKey: environment.parentEnvironment.apiKey,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
};
|
||||
});
|
||||
}
|
||||
case "organizationAccessToken": {
|
||||
const organization = await $replica.organization.findUnique({
|
||||
@@ -582,17 +573,14 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
projectId: project.id,
|
||||
slug: slug,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
},
|
||||
include: authIncludeBase,
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
throw json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return environment;
|
||||
return toAuthenticated(environment);
|
||||
}
|
||||
|
||||
const environment = await $replica.runtimeEnvironment.findFirst({
|
||||
@@ -602,11 +590,7 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
branchName: sanitizedBranch,
|
||||
archivedAt: null,
|
||||
},
|
||||
include: {
|
||||
project: true,
|
||||
organization: true,
|
||||
parentEnvironment: true,
|
||||
},
|
||||
include: authIncludeWithParent,
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
@@ -617,12 +601,10 @@ export async function authenticatedEnvironmentForAuthentication(
|
||||
throw json({ error: "Branch not associated with a preview environment" }, { status: 400 });
|
||||
}
|
||||
|
||||
return {
|
||||
return toAuthenticated({
|
||||
...environment,
|
||||
apiKey: environment.parentEnvironment.apiKey,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
};
|
||||
});
|
||||
}
|
||||
default: {
|
||||
auth satisfies never;
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed
|
||||
|
||||
const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams", "query", "prompts", "sessions"] as const;
|
||||
|
||||
export type AuthorizationResources = {
|
||||
[key in (typeof ResourceTypes)[number]]?: string | string[];
|
||||
};
|
||||
|
||||
export type AuthorizationEntity = {
|
||||
type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT";
|
||||
scopes?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if the given entity is authorized to perform a specific action on a resource.
|
||||
*
|
||||
* @param entity - The entity requesting authorization.
|
||||
* @param action - The action the entity wants to perform.
|
||||
* @param resource - The resource on which the action is to be performed.
|
||||
* @param superScopes - An array of super scopes that can bypass the normal authorization checks.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* import { checkAuthorization } from "./authorization.server";
|
||||
*
|
||||
* const entity = {
|
||||
* type: "PUBLIC",
|
||||
* scope: ["read:runs:run_1234", "read:tasks"]
|
||||
* };
|
||||
*
|
||||
* checkAuthorization(entity, "read", { runs: "run_1234" }); // Returns true
|
||||
* checkAuthorization(entity, "read", { runs: "run_5678" }); // Returns false
|
||||
* checkAuthorization(entity, "read", { tasks: "task_1234" }); // Returns true
|
||||
* checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true
|
||||
* ```
|
||||
*/
|
||||
export type AuthorizationResult = { authorized: true } | { authorized: false; reason: string };
|
||||
|
||||
/**
|
||||
* Checks if the given entity is authorized to perform a specific action on a resource.
|
||||
*/
|
||||
export function checkAuthorization(
|
||||
entity: AuthorizationEntity,
|
||||
action: AuthorizationAction,
|
||||
resource: AuthorizationResources,
|
||||
superScopes?: string[]
|
||||
): AuthorizationResult {
|
||||
// "PRIVATE" is a secret key and has access to everything
|
||||
if (entity.type === "PRIVATE") {
|
||||
return { authorized: true };
|
||||
}
|
||||
|
||||
// "PUBLIC" is a deprecated key and has no access
|
||||
if (entity.type === "PUBLIC") {
|
||||
return { authorized: false, reason: "PUBLIC type is deprecated and has no access" };
|
||||
}
|
||||
|
||||
// If the entity has no permissions, deny access
|
||||
if (!entity.scopes || entity.scopes.length === 0) {
|
||||
return {
|
||||
authorized: false,
|
||||
reason:
|
||||
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information.",
|
||||
};
|
||||
}
|
||||
|
||||
// If the resource object is empty, deny access
|
||||
if (Object.keys(resource).length === 0) {
|
||||
return { authorized: false, reason: "Resource object is empty" };
|
||||
}
|
||||
|
||||
// Check for any of the super scopes
|
||||
if (superScopes && superScopes.length > 0) {
|
||||
if (superScopes.some((permission) => entity.scopes?.includes(permission))) {
|
||||
return { authorized: true };
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResource = Object.keys(resource).reduce((acc, key) => {
|
||||
if (ResourceTypes.includes(key)) {
|
||||
acc[key as keyof AuthorizationResources] = resource[key as keyof AuthorizationResources];
|
||||
}
|
||||
return acc;
|
||||
}, {} as AuthorizationResources);
|
||||
|
||||
// Check each resource type
|
||||
for (const [resourceType, resourceValue] of Object.entries(filteredResource)) {
|
||||
const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue];
|
||||
|
||||
for (const value of resourceValues) {
|
||||
// Check for specific resource permission
|
||||
const specificPermission = `${action}:${resourceType}:${value}`;
|
||||
// Check for general resource type permission
|
||||
const generalPermission = `${action}:${resourceType}`;
|
||||
|
||||
// If any permission matches, return authorized
|
||||
if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) {
|
||||
return { authorized: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No matching permissions found
|
||||
return {
|
||||
authorized: false,
|
||||
reason: `Public Access Token is missing required permissions. Token has the following permissions: ${entity.scopes
|
||||
.map((s) => `'${s}'`)
|
||||
.join(
|
||||
", "
|
||||
)}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { SendEmailOptions } from "remix-auth-email-link";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
import type { AuthUser } from "./authUser";
|
||||
import { commonWorker } from "~/v3/commonWorker.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { assertEmailAllowed } from "~/utils/email";
|
||||
@@ -92,15 +91,6 @@ export async function sendPlainTextEmail(options: SendPlainTextOptions) {
|
||||
return client.sendPlainText(options);
|
||||
}
|
||||
|
||||
export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) {
|
||||
const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined;
|
||||
await commonWorker.enqueue({
|
||||
job: "scheduleEmail",
|
||||
payload: data,
|
||||
availableAt,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendEmail(data: DeliverEmail) {
|
||||
return client.send(data);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createHash } from "@better-auth/utils/hash";
|
||||
import { createOTP } from "@better-auth/utils/otp";
|
||||
import { base32 } from "@better-auth/utils/base32";
|
||||
import { z } from "zod";
|
||||
import { scheduleEmail } from "../email.server";
|
||||
import { scheduleEmail } from "../scheduleEmail.server";
|
||||
|
||||
const generateRandomString = createRandomStringGenerator("A-Z", "0-9");
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -19,6 +20,12 @@ export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000;
|
||||
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 */
|
||||
@@ -105,6 +112,43 @@ export type PersonalAccessTokenAuthenticationResult = {
|
||||
userId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Smart-skip the `lastAccessedAt` write when the cached value is already
|
||||
* within the throttle window. Saves one DB roundtrip per "fresh" auth.
|
||||
*
|
||||
* Two layers of throttling: JS-side (`Date.now() - lastAccessedAt.getTime()`)
|
||||
* elides the SQL entirely when the caller already has a recent timestamp;
|
||||
* SQL-side `WHERE` clause inside the `updateMany` guards against concurrent
|
||||
* auths racing to a double-write when the JS check decides to fire.
|
||||
*
|
||||
* Called from both the legacy PAT flow (`authenticatePersonalAccessToken`)
|
||||
* and the apiBuilder's RBAC-routed PAT flow (which gets `lastAccessedAt`
|
||||
* from `rbac.authenticatePat`'s result). Keeps the throttle policy in one
|
||||
* place rather than expecting every plugin implementer to re-implement it.
|
||||
*/
|
||||
export async function updateLastAccessedAtIfStale(
|
||||
tokenId: string,
|
||||
lastAccessedAt: Date | null
|
||||
): Promise<void> {
|
||||
if (
|
||||
lastAccessedAt &&
|
||||
Date.now() - lastAccessedAt.getTime() <= PAT_LAST_ACCESSED_THROTTLE_MS
|
||||
) {
|
||||
return; // fresh — no roundtrip
|
||||
}
|
||||
await prisma.personalAccessToken.updateMany({
|
||||
where: {
|
||||
id: tokenId,
|
||||
revokedAt: null,
|
||||
OR: [
|
||||
{ lastAccessedAt: null },
|
||||
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },
|
||||
],
|
||||
},
|
||||
data: { lastAccessedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
const EncryptedSecretValueSchema = z.object({
|
||||
nonce: z.string(),
|
||||
ciphertext: z.string(),
|
||||
@@ -211,24 +255,7 @@ export async function authenticatePersonalAccessToken(
|
||||
return;
|
||||
}
|
||||
|
||||
// Conditional updateMany — only writes if the existing lastAccessedAt is
|
||||
// null or older than the throttle window. The WHERE runs inside the UPDATE
|
||||
// so concurrent auths don't race into a double-write. `revokedAt: null`
|
||||
// matches the findFirst guard above so a token revoked between the read
|
||||
// and write doesn't get a stale lastAccessedAt update.
|
||||
await prisma.personalAccessToken.updateMany({
|
||||
where: {
|
||||
id: personalAccessToken.id,
|
||||
revokedAt: null,
|
||||
OR: [
|
||||
{ lastAccessedAt: null },
|
||||
{ lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
lastAccessedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await updateLastAccessedAtIfStale(personalAccessToken.id, personalAccessToken.lastAccessedAt);
|
||||
|
||||
const decryptedToken = decryptPersonalAccessToken(personalAccessToken);
|
||||
|
||||
@@ -338,6 +365,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 +380,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".
|
||||
//
|
||||
// Skip the call entirely when no RBAC plugin is loaded — the OSS
|
||||
// fallback has no TokenRole table to write to. Gating on
|
||||
// `rbac.isUsingPlugin()` (rather than parsing the fallback's error
|
||||
// string) keeps the OSS-vs-cloud branch explicit and decoupled from
|
||||
// any specific error message.
|
||||
if (roleId && (await rbac.isUsingPlugin())) {
|
||||
const roleResult = await rbac.setTokenRole({
|
||||
tokenId: personalAccessToken.id,
|
||||
roleId,
|
||||
});
|
||||
if (!roleResult.ok) {
|
||||
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}`);
|
||||
}
|
||||
} else if (roleId) {
|
||||
logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", {
|
||||
patId: personalAccessToken.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: personalAccessToken.id,
|
||||
name,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3";
|
||||
import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import type { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import {
|
||||
BillingClient,
|
||||
defaultMachine as defaultMachineFromPlatform,
|
||||
@@ -25,7 +25,6 @@ import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createEnvironment } from "~/models/organization.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
@@ -598,33 +597,6 @@ export async function getEntitlement(
|
||||
return result.val;
|
||||
}
|
||||
|
||||
export async function projectCreated(
|
||||
organization: Pick<Organization, "id" | "maximumConcurrencyLimit">,
|
||||
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<BillingAlertsResult | undefined> {
|
||||
@@ -789,7 +761,7 @@ export async function triggerInitialDeployment(
|
||||
}
|
||||
}
|
||||
|
||||
function isCloud(): boolean {
|
||||
export function isCloud(): boolean {
|
||||
const acceptableHosts = [
|
||||
"https://cloud.trigger.dev",
|
||||
"https://test-cloud.trigger.dev",
|
||||
|
||||
@@ -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<Organization, "id" | "maximumConcurrencyLimit">,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import type { PrismaClient } from "@trigger.dev/database";
|
||||
import plugin from "@trigger.dev/rbac";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
// plugin.create() is synchronous — returns a lazy controller that resolves
|
||||
// any installed RBAC plugin on first call. Top-level await is not used
|
||||
// because CJS output format does not support it.
|
||||
//
|
||||
// Auth-path reads run on every request — pass the replica explicitly so
|
||||
// they don't pile up on the primary. Writes (role mutations) still go
|
||||
// through the primary. Same separation findEnvironmentByApiKey used
|
||||
// before this PR moved bearer auth into the RBAC plugin.
|
||||
//
|
||||
// Session-cookie userId resolution lives at the call site (see
|
||||
// dashboardBuilder.server.ts), not here. Statically importing
|
||||
// `~/services/session.server` from this module dragged the entire
|
||||
// remix-auth pipeline (auth.server → emailAuth/gitHubAuth/googleAuth,
|
||||
// each validating their secret at module load) into anything that
|
||||
// transitively imported `rbac` — including PAT auth callers that have
|
||||
// no session-cookie path at all. Passing userId through the
|
||||
// `authenticateSession` context decouples the plugin host from the
|
||||
// host's session implementation.
|
||||
export const rbac = plugin.create(
|
||||
// $replica is structurally a PrismaClient minus `$transaction` — the
|
||||
// RBAC fallback only uses `findFirst` on it, so the cast is safe.
|
||||
{ primary: prisma, replica: $replica as PrismaClient },
|
||||
{ forceFallback: env.RBAC_FORCE_FALLBACK }
|
||||
);
|
||||
@@ -126,9 +126,7 @@ export function isPublicJWT(token: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function extractJwtSigningSecretKey(
|
||||
environment: AuthenticatedEnvironment & { parentEnvironment?: { apiKey: string } }
|
||||
) {
|
||||
export function extractJwtSigningSecretKey(environment: AuthenticatedEnvironment) {
|
||||
return environment.parentEnvironment?.apiKey ?? environment.apiKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiAuthenticationResultSuccess,
|
||||
authenticateApiRequestWithFailure,
|
||||
} from "../apiAuth.server";
|
||||
import { ApiAuthenticationResultSuccess } from "../apiAuth.server";
|
||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
import {
|
||||
AuthorizationAction,
|
||||
AuthorizationResources,
|
||||
checkAuthorization,
|
||||
} from "../authorization.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { rbac } from "../rbac.server";
|
||||
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
|
||||
import {
|
||||
authenticateApiRequestWithPersonalAccessToken,
|
||||
PersonalAccessTokenAuthenticationResult,
|
||||
updateLastAccessedAtIfStale,
|
||||
} from "../personalAccessToken.server";
|
||||
import { safeJsonParse } from "~/utils/json";
|
||||
import {
|
||||
@@ -50,8 +44,126 @@ function logBoundaryError(
|
||||
}
|
||||
}
|
||||
|
||||
// Bridges the RBAC plugin (source of truth for auth + abilities) to the legacy
|
||||
// ApiAuthenticationResultSuccess shape route handlers still expect. All three
|
||||
// apiBuilder call sites funnel through this helper — no handler-level changes
|
||||
// needed.
|
||||
async function authenticateRequestForApiBuilder(
|
||||
request: Request,
|
||||
{ allowJWT }: { allowJWT: boolean }
|
||||
): Promise<
|
||||
| { ok: false; status: 401 | 403; error: string }
|
||||
| { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility }
|
||||
> {
|
||||
const result = await rbac.authenticateBearer(request, { allowJWT });
|
||||
if (!result.ok) {
|
||||
// Plugin auth distinguishes 401 (who are you?) from 403 (you're not
|
||||
// allowed) — e.g. a suspended account or IP block returns 403.
|
||||
// Forwarding the status preserves that semantic for client retry logic.
|
||||
return { ok: false, status: result.status, error: result.error };
|
||||
}
|
||||
|
||||
// Plugins return the full AuthenticatedEnvironment shape directly — no
|
||||
// follow-up DB lookup. The fallback fetches via Prisma, the cloud plugin
|
||||
// via Drizzle; both produce the same slim contract type.
|
||||
const authentication: ApiAuthenticationResultSuccess = {
|
||||
ok: true,
|
||||
apiKey: result.environment.apiKey,
|
||||
type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE",
|
||||
environment: result.environment,
|
||||
realtime: result.jwt?.realtime,
|
||||
oneTimeUse: result.jwt?.oneTimeUse,
|
||||
};
|
||||
|
||||
return { ok: true, authentication, ability: result.ability };
|
||||
}
|
||||
|
||||
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
|
||||
// A multi-resource auth check has two possible directions, and route authors
|
||||
// have to pick one explicitly:
|
||||
//
|
||||
// - `anyResource(...)` — succeed if *any* element passes. Used when a single
|
||||
// record carries multiple identifiers (a run is addressable by friendlyId /
|
||||
// batch / tags / task) so a JWT scoped to *any* of them grants access.
|
||||
//
|
||||
// - `everyResource(...)` — succeed only if *every* element passes. Used for
|
||||
// batch operations where each element is a *distinct* resource and a JWT
|
||||
// scoped to one element must not authorize the others.
|
||||
//
|
||||
// Bare `RbacResource[]` is intentionally *not* part of `AuthResource` — the
|
||||
// type system forces every multi-resource site to disambiguate. The original
|
||||
// pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for
|
||||
// "broader-than-this-resource" access; post-RBAC that's expressed via the JWT
|
||||
// ability's wildcard branches (`*:all` and `admin*` — see
|
||||
// `internal-packages/rbac/src/ability.ts`) plus a collection-level shape
|
||||
// `{ type: "<subject>" }` (no id) in the `anyResource` array so a
|
||||
// `<action>:<subject>` JWT matches it. No code knob needed.
|
||||
//
|
||||
// Markers are Symbols so they can't collide with arbitrary RbacResource fields.
|
||||
const ANY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.anyResource");
|
||||
const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource");
|
||||
|
||||
type AnyResourceAuth = {
|
||||
readonly [ANY_RESOURCE_MARKER]: true;
|
||||
readonly resources: readonly RbacResource[];
|
||||
};
|
||||
|
||||
type EveryResourceAuth = {
|
||||
readonly [EVERY_RESOURCE_MARKER]: true;
|
||||
readonly resources: readonly RbacResource[];
|
||||
};
|
||||
|
||||
export function anyResource(resources: RbacResource[]): AnyResourceAuth {
|
||||
return { [ANY_RESOURCE_MARKER]: true, resources };
|
||||
}
|
||||
|
||||
export function everyResource(resources: RbacResource[]): EveryResourceAuth {
|
||||
return { [EVERY_RESOURCE_MARKER]: true, resources };
|
||||
}
|
||||
|
||||
function isAnyResource(value: unknown): value is AnyResourceAuth {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as Record<symbol, unknown>)[ANY_RESOURCE_MARKER] === true
|
||||
);
|
||||
}
|
||||
|
||||
function isEveryResource(value: unknown): value is EveryResourceAuth {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
(value as Record<symbol, unknown>)[EVERY_RESOURCE_MARKER] === true
|
||||
);
|
||||
}
|
||||
|
||||
type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth;
|
||||
|
||||
function checkAuth(
|
||||
ability: RbacAbility,
|
||||
action: string,
|
||||
resource: AuthResource
|
||||
): boolean {
|
||||
if (isEveryResource(resource)) {
|
||||
// Empty array via [].every() is vacuously true — would let any token
|
||||
// pass auth. Routes building everyResource() from request bodies
|
||||
// (e.g. batch trigger items) should never produce zero elements
|
||||
// because body validation rejects empty arrays first, but defending
|
||||
// here anyway since the auth layer should never grant on no input.
|
||||
if (resource.resources.length === 0) return false;
|
||||
return resource.resources.every((r) => ability.can(action, r));
|
||||
}
|
||||
if (isAnyResource(resource)) {
|
||||
// Symmetric guard: anyResource([]) is benign for most abilities
|
||||
// (.some() is false on empty), but the permissive ability would
|
||||
// still grant. Treat empty as "no resource declared" → deny.
|
||||
if (resource.resources.length === 0) return false;
|
||||
return ability.can(action, [...resource.resources]);
|
||||
}
|
||||
return ability.can(action, resource);
|
||||
}
|
||||
|
||||
type ApiKeyRouteBuilderOptions<
|
||||
TParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
|
||||
@@ -76,7 +188,7 @@ type ApiKeyRouteBuilderOptions<
|
||||
) => Promise<TResource | undefined>;
|
||||
shouldRetryNotFound?: boolean;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
action: string;
|
||||
resource: (
|
||||
resource: NonNullable<TResource>,
|
||||
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
@@ -90,8 +202,7 @@ type ApiKeyRouteBuilderOptions<
|
||||
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<THeadersSchema>
|
||||
: undefined
|
||||
) => AuthorizationResources;
|
||||
superScopes?: string[];
|
||||
) => AuthResource;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -144,23 +255,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 +330,7 @@ export function createLoaderApiRoute<
|
||||
}
|
||||
|
||||
if (authorization) {
|
||||
const { action, resource: authResource, superScopes } = authorization;
|
||||
const { action, resource: authResource } = authorization;
|
||||
const $authResource = authResource(
|
||||
resource,
|
||||
parsedParams,
|
||||
@@ -235,26 +338,12 @@ export function createLoaderApiRoute<
|
||||
parsedHeaders
|
||||
);
|
||||
|
||||
logger.debug("Checking authorization", {
|
||||
action,
|
||||
resource: $authResource,
|
||||
superScopes,
|
||||
scopes: authenticationResult.scopes,
|
||||
});
|
||||
|
||||
const authorizationResult = checkAuthorization(
|
||||
authenticationResult,
|
||||
action,
|
||||
$authResource,
|
||||
superScopes
|
||||
);
|
||||
|
||||
if (!authorizationResult.authorized) {
|
||||
if (!checkAuth(ability, action, $authResource)) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
error: `Unauthorized: ${authorizationResult.reason}`,
|
||||
error: "Unauthorized",
|
||||
code: "unauthorized",
|
||||
param: "access_token",
|
||||
type: "authorization",
|
||||
@@ -309,6 +398,37 @@ type PATRouteBuilderOptions<
|
||||
searchParams?: TSearchParamsSchema;
|
||||
headers?: THeadersSchema;
|
||||
corsStrategy?: "all" | "none";
|
||||
// Resolves the target org/project for the request. Fed to
|
||||
// `rbac.authenticatePat` so the plugin can compute the user's role
|
||||
// floor (their authority in that org) for the cap intersection.
|
||||
// When omitted, the PAT runs in identity-only mode — no role floor,
|
||||
// no per-route ability gating beyond what authorization (if any)
|
||||
// declares against a permissive baseline. Routes added before TRI-9087
|
||||
// run in this mode by default.
|
||||
context?: (
|
||||
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined,
|
||||
request: Request
|
||||
) =>
|
||||
| { organizationId?: string; projectId?: string }
|
||||
| Promise<{ organizationId?: string; projectId?: string }>;
|
||||
authorization?: {
|
||||
action: string;
|
||||
resource: (
|
||||
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
: undefined,
|
||||
searchParams: TSearchParamsSchema extends
|
||||
| z.ZodFirstPartySchemaTypes
|
||||
| z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TSearchParamsSchema>
|
||||
: undefined,
|
||||
headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<THeadersSchema>
|
||||
: undefined
|
||||
) => AuthResource;
|
||||
};
|
||||
};
|
||||
|
||||
type PATHandlerFunction<
|
||||
@@ -328,6 +448,7 @@ type PATHandlerFunction<
|
||||
? z.infer<THeadersSchema>
|
||||
: undefined;
|
||||
authentication: PersonalAccessTokenAuthenticationResult;
|
||||
ability: RbacAbility;
|
||||
request: Request;
|
||||
apiVersion: API_VERSIONS;
|
||||
}) => Promise<Response>;
|
||||
@@ -346,6 +467,8 @@ export function createLoaderPATApiRoute<
|
||||
searchParams: searchParamsSchema,
|
||||
headers: headersSchema,
|
||||
corsStrategy = "none",
|
||||
context: contextFn,
|
||||
authorization,
|
||||
} = options;
|
||||
|
||||
if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
|
||||
@@ -353,16 +476,6 @@ export function createLoaderPATApiRoute<
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
let parsedParams: any = undefined;
|
||||
if (paramsSchema) {
|
||||
const parsed = paramsSchema.safeParse(params);
|
||||
@@ -415,11 +528,70 @@ export function createLoaderPATApiRoute<
|
||||
|
||||
const apiVersion = getApiVersion(request);
|
||||
|
||||
// Single PAT auth roundtrip. `rbac.authenticatePat` validates the
|
||||
// token AND computes the cap-and-floor ability in one DB query
|
||||
// (the OSS fallback does the validation only and returns a
|
||||
// permissive ability; the cloud plugin returns the joined
|
||||
// cap/floor result). We previously called
|
||||
// `authenticateApiRequestWithPersonalAccessToken` here first as
|
||||
// belt-and-braces, but that meant two PAT lookups per request
|
||||
// for routes with `context`/`authorization` declared. Routes
|
||||
// without those still get a working `authentication` object —
|
||||
// we pass an empty ctx and the fallback validates fine.
|
||||
//
|
||||
// `lastAccessedAt` is plumbed through the plugin result so the
|
||||
// host can decide whether to fire the update (smart-skip in
|
||||
// `updateLastAccessedAtIfStale` — no DB roundtrip when the
|
||||
// cached timestamp is fresher than the throttle window).
|
||||
const ctx = contextFn ? await contextFn(parsedParams, request) : {};
|
||||
const patAuth = await rbac.authenticatePat(request, ctx);
|
||||
if (!patAuth.ok) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: patAuth.error }, { status: patAuth.status }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
const authenticationResult: PersonalAccessTokenAuthenticationResult = {
|
||||
userId: patAuth.userId,
|
||||
};
|
||||
const ability: RbacAbility = patAuth.ability;
|
||||
|
||||
// Fire the `lastAccessedAt` write conditionally. Two-layer throttle:
|
||||
// JS skips the SQL when the value is fresh (most requests); the
|
||||
// SQL `WHERE` clause inside the helper is race-safe for concurrent
|
||||
// auths that both decide to fire. Don't `await` it from the
|
||||
// critical path? — it's a one-row update on a small hot table and
|
||||
// we want to surface failures, so it's awaited (same shape as the
|
||||
// legacy `authenticatePersonalAccessToken`).
|
||||
await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt);
|
||||
|
||||
if (authorization) {
|
||||
const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders);
|
||||
if (!checkAuth(ability, authorization.action, $resource)) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json(
|
||||
{
|
||||
error: "Unauthorized",
|
||||
code: "unauthorized",
|
||||
param: "access_token",
|
||||
type: "authorization",
|
||||
},
|
||||
{ status: 403 }
|
||||
),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await handler({
|
||||
params: parsedParams,
|
||||
searchParams: parsedSearchParams,
|
||||
headers: parsedHeaders,
|
||||
authentication: authenticationResult,
|
||||
ability,
|
||||
request,
|
||||
apiVersion,
|
||||
});
|
||||
@@ -468,7 +640,7 @@ type ApiKeyActionRouteBuilderOptions<
|
||||
: undefined
|
||||
) => Promise<TResource | undefined>;
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
action: string;
|
||||
resource: (
|
||||
params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<TParamsSchema>
|
||||
@@ -490,8 +662,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 +750,15 @@ export function createActionApiRoute<
|
||||
}
|
||||
|
||||
try {
|
||||
const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT });
|
||||
|
||||
if (!authenticationResult) {
|
||||
const authResult = await authenticateRequestForApiBuilder(request, { allowJWT });
|
||||
if (!authResult.ok) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: "Invalid or Missing API key" }, { status: 401 }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticationResult.ok) {
|
||||
return await wrapResponse(
|
||||
request,
|
||||
json({ error: authenticationResult.error }, { status: 401 }),
|
||||
json({ error: authResult.error }, { status: authResult.status }),
|
||||
corsStrategy !== "none"
|
||||
);
|
||||
}
|
||||
const { authentication: authenticationResult, ability } = authResult;
|
||||
|
||||
if (maxContentLength) {
|
||||
const contentLength = request.headers.get("content-length");
|
||||
@@ -706,7 +869,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 +878,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 +974,8 @@ type MultiMethodApiRouteOptions<
|
||||
allowJWT?: boolean;
|
||||
corsStrategy?: "all" | "none";
|
||||
authorization?: {
|
||||
action: AuthorizationAction;
|
||||
resource: (params: InferZod<TParamsSchema>) => AuthorizationResources;
|
||||
superScopes?: string[];
|
||||
action: string;
|
||||
resource: (params: InferZod<TParamsSchema>) => AuthResource;
|
||||
};
|
||||
maxContentLength?: number;
|
||||
methods: Partial<
|
||||
@@ -872,33 +1020,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 +1103,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",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Server-only impl backing dashboardBuilder.ts. Imports rbac.server and
|
||||
// runs the actual auth/authorization. The wrappers in dashboardBuilder.ts
|
||||
// dynamic-import this module from inside the loader/action body, so it
|
||||
// never reaches the client bundle.
|
||||
|
||||
import { json, redirect } from "@remix-run/server-runtime";
|
||||
import type { RbacAbility } from "@trigger.dev/rbac";
|
||||
import { rbac } from "~/services/rbac.server";
|
||||
import { getUserId } from "~/services/session.server";
|
||||
import type {
|
||||
AuthorizationOption,
|
||||
DashboardLoaderOptions,
|
||||
SessionUser,
|
||||
} from "./dashboardBuilder";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { z } from "zod";
|
||||
|
||||
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
|
||||
function loginRedirectFor(request: Request, override?: string): Response {
|
||||
if (override) return redirect(override);
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
|
||||
return redirect(`/login?redirectTo=${redirectTo}`);
|
||||
}
|
||||
|
||||
function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean {
|
||||
if ("requireSuper" in authorization) {
|
||||
return ability.canSuper();
|
||||
}
|
||||
return ability.can(authorization.action, authorization.resource);
|
||||
}
|
||||
|
||||
type AuthScope = { organizationId?: string; projectId?: string };
|
||||
|
||||
export async function authenticateAndAuthorize<
|
||||
TParams,
|
||||
TSearchParams,
|
||||
TContext extends AuthScope
|
||||
>(
|
||||
request: Request,
|
||||
rawParams: unknown,
|
||||
options: DashboardLoaderOptions<TParams, TSearchParams, TContext>
|
||||
): Promise<
|
||||
| { ok: false; response: Response }
|
||||
| {
|
||||
ok: true;
|
||||
user: SessionUser;
|
||||
ability: RbacAbility;
|
||||
params: unknown;
|
||||
searchParams: unknown;
|
||||
context: TContext;
|
||||
}
|
||||
> {
|
||||
let parsedParams: any = undefined;
|
||||
if (options.params) {
|
||||
const parsed = (options.params as unknown as AnyZodSchema).safeParse(rawParams);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: json(
|
||||
{ error: "Params Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
parsedParams = parsed.data;
|
||||
}
|
||||
|
||||
let parsedSearchParams: any = undefined;
|
||||
if (options.searchParams) {
|
||||
const fromUrl = Object.fromEntries(new URL(request.url).searchParams);
|
||||
const parsed = (options.searchParams as unknown as AnyZodSchema).safeParse(fromUrl);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
response: json(
|
||||
{ error: "Query Error", details: fromZodError(parsed.error).details },
|
||||
{ status: 400 }
|
||||
),
|
||||
};
|
||||
}
|
||||
parsedSearchParams = parsed.data;
|
||||
}
|
||||
|
||||
const ctx = (options.context
|
||||
? await options.context(parsedParams, request)
|
||||
: ({} as TContext)) as TContext;
|
||||
// Resolve userId from the session cookie *here* (the dashboard
|
||||
// request boundary) and feed it into the rbac plugin context. The
|
||||
// plugin no longer takes a `helpers.getSessionUserId` callback —
|
||||
// statically importing session.server from rbac.server dragged the
|
||||
// entire remix-auth strategy chain (each strategy validates its
|
||||
// secret at module load) into anything that pulled `rbac` in,
|
||||
// including PAT-only callers.
|
||||
const userId = (await getUserId(request)) ?? null;
|
||||
const auth = await rbac.authenticateSession(request, { ...ctx, userId });
|
||||
if (!auth.ok) {
|
||||
if (auth.reason === "unauthenticated") {
|
||||
return { ok: false, response: loginRedirectFor(request, options.loginRedirect) };
|
||||
}
|
||||
return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
|
||||
}
|
||||
|
||||
if (options.authorization && !isAuthorized(auth.ability, options.authorization)) {
|
||||
return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
user: auth.user,
|
||||
ability: auth.ability,
|
||||
params: parsedParams,
|
||||
searchParams: parsedSearchParams,
|
||||
context: ctx,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Client-safe shim for the dashboard route builder. The actual server
|
||||
// implementation lives in dashboardBuilder.server.ts; the wrappers here
|
||||
// just return closures that lazily import that impl on first invocation.
|
||||
//
|
||||
// Why split: routes use `export const loader = dashboardLoader(...)` at
|
||||
// module top-level. Remix's dev build preserves the top-level call when
|
||||
// resolving the loader export, so the import target needs to exist on
|
||||
// the client even though the closure body never executes there. A
|
||||
// `.server.ts` file is excluded from the client bundle, which would
|
||||
// resolve `dashboardLoader` to undefined and crash with
|
||||
// "dashboardLoader is not a function" on first navigation. Keeping this
|
||||
// file non-`.server` puts the wrappers in the client bundle as
|
||||
// effectively no-op closures (they're never called there), and the
|
||||
// closure body's dynamic import only resolves at server runtime.
|
||||
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import type { RbacAbility, RbacResource } from "@trigger.dev/rbac";
|
||||
import type { z } from "zod";
|
||||
|
||||
type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>;
|
||||
|
||||
type InferZod<T> = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion<any, any>
|
||||
? z.infer<T>
|
||||
: undefined;
|
||||
|
||||
export type SessionUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
displayName: string | null;
|
||||
avatarUrl: string | null;
|
||||
admin: boolean;
|
||||
confirmedBasicDetails: boolean;
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
|
||||
// `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit
|
||||
// action + resource pair is checked via ability.can(...).
|
||||
export type AuthorizationOption =
|
||||
| { requireSuper: true }
|
||||
| {
|
||||
action: string;
|
||||
resource: RbacResource | RbacResource[];
|
||||
};
|
||||
|
||||
// Plugin-side scope: whatever the route's `context` returns must include
|
||||
// these (or just be `{}` when the route doesn't scope by org/project).
|
||||
// rbac.authenticateSession reads them off the value to filter UserRole.
|
||||
type AuthScope = { organizationId?: string; projectId?: string };
|
||||
|
||||
export type DashboardLoaderOptions<TParams, TSearchParams, TContext extends AuthScope> = {
|
||||
params?: TParams;
|
||||
searchParams?: TSearchParams;
|
||||
// Resolves any per-request data the handler + auth check both need
|
||||
// (typically org/project lookups from URL params). The returned object
|
||||
// is fed to `rbac.authenticateSession` as the auth scope AND passed
|
||||
// through to the handler in `args.context`, so the route does each
|
||||
// lookup once.
|
||||
context?: (
|
||||
params: InferZod<TParams>,
|
||||
request: Request
|
||||
) => TContext | Promise<TContext>;
|
||||
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<TParams, TSearchParams, TContext> = {
|
||||
params: InferZod<TParams>;
|
||||
searchParams: InferZod<TSearchParams>;
|
||||
user: SessionUser;
|
||||
ability: RbacAbility;
|
||||
context: TContext;
|
||||
request: Request;
|
||||
};
|
||||
|
||||
export function dashboardLoader<
|
||||
TParams extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParams extends AnyZodSchema | undefined = undefined,
|
||||
TContext extends AuthScope = AuthScope,
|
||||
TReturn extends Response = Response
|
||||
>(
|
||||
options: DashboardLoaderOptions<TParams, TSearchParams, TContext>,
|
||||
handler: (
|
||||
args: DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext>
|
||||
) => Promise<TReturn>
|
||||
) {
|
||||
return async function loader({ request, params }: LoaderFunctionArgs): Promise<TReturn> {
|
||||
// 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<TParams>,
|
||||
searchParams: result.searchParams as InferZod<TSearchParams>,
|
||||
user: result.user,
|
||||
ability: result.ability,
|
||||
context: result.context as TContext,
|
||||
request,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardActionOptions<TParams, TSearchParams, TContext extends AuthScope> =
|
||||
DashboardLoaderOptions<TParams, TSearchParams, TContext>;
|
||||
|
||||
export type DashboardActionHandlerArgs<TParams, TSearchParams, TContext> =
|
||||
DashboardLoaderHandlerArgs<TParams, TSearchParams, TContext>;
|
||||
|
||||
export function dashboardAction<
|
||||
TParams extends AnyZodSchema | undefined = undefined,
|
||||
TSearchParams extends AnyZodSchema | undefined = undefined,
|
||||
TContext extends AuthScope = AuthScope,
|
||||
TReturn extends Response = Response
|
||||
>(
|
||||
options: DashboardActionOptions<TParams, TSearchParams, TContext>,
|
||||
handler: (
|
||||
args: DashboardActionHandlerArgs<TParams, TSearchParams, TContext>
|
||||
) => Promise<TReturn>
|
||||
) {
|
||||
return async function action({ request, params }: ActionFunctionArgs): Promise<TReturn> {
|
||||
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<TParams>,
|
||||
searchParams: result.searchParams as InferZod<TSearchParams>,
|
||||
user: result.user,
|
||||
ability: result.ability,
|
||||
context: result.context as TContext,
|
||||
request,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DeliverEmail } from "emails";
|
||||
import { commonWorker } from "~/v3/commonWorker.server";
|
||||
|
||||
// Lives outside email.server.ts so that the SMTP/Resend client module
|
||||
// stays a leaf dependency. Pulling commonWorker from email.server poisoned
|
||||
// every consumer of the auth chain (auth → emailAuth → email) with the
|
||||
// V1+V2 worker tree, which transitively loads marqs and trips Redis-env
|
||||
// guards in any vitest file whose import graph reaches it.
|
||||
export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) {
|
||||
const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined;
|
||||
await commonWorker.enqueue({
|
||||
job: "scheduleEmail",
|
||||
payload: data,
|
||||
availableAt,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import slug from "slug";
|
||||
import { prisma } from "~/db.server";
|
||||
import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server";
|
||||
import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route";
|
||||
import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch";
|
||||
import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch";
|
||||
import { logger } from "./logger.server";
|
||||
import { getCurrentPlan, getLimit } from "./platform.v3.server";
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment";
|
||||
import { z } from "zod";
|
||||
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
@@ -876,8 +877,21 @@ export const RuntimeEnvironmentForEnvRepoPayload = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload<
|
||||
typeof RuntimeEnvironmentForEnvRepoPayload
|
||||
// Derived from the slim AuthenticatedEnvironment so a full AE satisfies
|
||||
// this type — the legacy Prisma payload had `builtInEnvironmentVariableOverrides`
|
||||
// as Prisma's JsonValue, which is a subtype of `unknown` in the slim
|
||||
// shape, causing assignability errors in the JWT/queue paths that pass
|
||||
// AE values straight through. Using Pick<AE, ...> aligns them.
|
||||
export type RuntimeEnvironmentForEnvRepo = Pick<
|
||||
AuthenticatedEnvironment,
|
||||
| "id"
|
||||
| "slug"
|
||||
| "type"
|
||||
| "projectId"
|
||||
| "apiKey"
|
||||
| "organizationId"
|
||||
| "branchName"
|
||||
| "builtInEnvironmentVariableOverrides"
|
||||
>;
|
||||
|
||||
export const environmentVariablesRepository = new EnvironmentVariablesRepository();
|
||||
@@ -1333,10 +1347,13 @@ function resolveBuiltInEnvironmentVariableOverrides(
|
||||
if (
|
||||
!Array.isArray(overrides) &&
|
||||
typeof overrides === "object" &&
|
||||
key in overrides &&
|
||||
typeof overrides[key] === "string"
|
||||
overrides !== null &&
|
||||
key in overrides
|
||||
) {
|
||||
return overrides[key];
|
||||
const value = (overrides as Record<string, unknown>)[key];
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { depot } from "@depot/sdk-node";
|
||||
import { type ExternalBuildData } from "@trigger.dev/core/v3";
|
||||
import { type Project } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import pRetry from "p-retry";
|
||||
import { logger } from "~/services/logger.server";
|
||||
|
||||
// Just the project columns this module reads — keeps the signature
|
||||
// compatible with both the full Prisma `Project` payload and the slim
|
||||
// `AuthenticatedEnvironment["project"]` shape.
|
||||
type ProjectForBuilder = {
|
||||
id: string;
|
||||
externalRef: string;
|
||||
builderProjectId: string | null;
|
||||
};
|
||||
|
||||
export async function createRemoteImageBuild(
|
||||
project: Project
|
||||
project: ProjectForBuilder
|
||||
): Promise<ExternalBuildData | undefined> {
|
||||
if (!remoteBuildsEnabled()) {
|
||||
return;
|
||||
@@ -42,7 +50,7 @@ export async function createRemoteImageBuild(
|
||||
};
|
||||
}
|
||||
|
||||
async function createBuilderProjectIfNotExists(project: Project) {
|
||||
async function createBuilderProjectIfNotExists(project: ProjectForBuilder) {
|
||||
if (project.builderProjectId) {
|
||||
return project.builderProjectId;
|
||||
}
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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:<tag> 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:<friendlyId> 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);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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/<id>/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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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=<path>.
|
||||
// 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=<path>", 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("/");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,423 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { checkAuthorization, AuthorizationEntity } from "../app/services/authorization.server";
|
||||
|
||||
describe("checkAuthorization", () => {
|
||||
// Test entities
|
||||
const privateEntity: AuthorizationEntity = { type: "PRIVATE" };
|
||||
const publicEntity: AuthorizationEntity = { type: "PUBLIC" };
|
||||
const publicJwtEntityWithPermissions: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:runs:run_1234", "read:tasks", "read:tags:tag_5678"],
|
||||
};
|
||||
const publicJwtEntityNoPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
|
||||
const publicJwtEntityWithTaskWritePermissions: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["write:tasks:task-1"],
|
||||
};
|
||||
|
||||
describe("PRIVATE entity", () => {
|
||||
it("should always return authorized regardless of action or resource", () => {
|
||||
const result1 = checkAuthorization(privateEntity, "read", { runs: "run_1234" });
|
||||
expect(result1.authorized).toBe(true);
|
||||
expect(result1).not.toHaveProperty("reason");
|
||||
|
||||
const result2 = checkAuthorization(privateEntity, "read", { tasks: ["task_1", "task_2"] });
|
||||
expect(result2.authorized).toBe(true);
|
||||
expect(result2).not.toHaveProperty("reason");
|
||||
|
||||
const result3 = checkAuthorization(privateEntity, "read", { tags: "nonexistent_tag" });
|
||||
expect(result3.authorized).toBe(true);
|
||||
expect(result3).not.toHaveProperty("reason");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC entity", () => {
|
||||
it("should always return unauthorized with reason regardless of action or resource", () => {
|
||||
const result1 = checkAuthorization(publicEntity, "read", { runs: "run_1234" });
|
||||
expect(result1.authorized).toBe(false);
|
||||
if (!result1.authorized) {
|
||||
expect(result1.reason).toBe("PUBLIC type is deprecated and has no access");
|
||||
}
|
||||
|
||||
const result2 = checkAuthorization(publicEntity, "read", { tasks: ["task_1", "task_2"] });
|
||||
expect(result2.authorized).toBe(false);
|
||||
if (!result2.authorized) {
|
||||
expect(result2.reason).toBe("PUBLIC type is deprecated and has no access");
|
||||
}
|
||||
|
||||
const result3 = checkAuthorization(publicEntity, "read", { tags: "tag_5678" });
|
||||
expect(result3.authorized).toBe(false);
|
||||
if (!result3.authorized) {
|
||||
expect(result3.reason).toBe("PUBLIC type is deprecated and has no access");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC_JWT entity with task write scope", () => {
|
||||
it("should return authorized for specific resource scope", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
|
||||
tasks: "task-1",
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return unauthorized with reason for unauthorized specific resources", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", {
|
||||
tasks: "task-2",
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'write:tasks:task-1'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC_JWT entity with scope", () => {
|
||||
it("should return authorized for specific resource scope", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_1234",
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return unauthorized with reason for unauthorized specific resources", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_5678",
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return authorized for general resource type scope", () => {
|
||||
const result1 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
tasks: "task_1234",
|
||||
});
|
||||
expect(result1.authorized).toBe(true);
|
||||
expect(result1).not.toHaveProperty("reason");
|
||||
|
||||
const result2 = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
tasks: ["task_5678", "task_9012"],
|
||||
});
|
||||
expect(result2.authorized).toBe(true);
|
||||
expect(result2).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return authorized if any resource in an array is authorized", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
tags: ["tag_1234", "tag_5678"],
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return authorized for nonexistent resource types", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
// @ts-expect-error
|
||||
nonexistent: "resource",
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
expect(result).toHaveProperty("reason");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUBLIC_JWT entity without scope", () => {
|
||||
it("should always return unauthorized with reason regardless of action or resource", () => {
|
||||
const result1 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
|
||||
runs: "run_1234",
|
||||
});
|
||||
expect(result1.authorized).toBe(false);
|
||||
if (!result1.authorized) {
|
||||
expect(result1.reason).toBe(
|
||||
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
|
||||
const result2 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
|
||||
tasks: ["task_1", "task_2"],
|
||||
});
|
||||
expect(result2.authorized).toBe(false);
|
||||
if (!result2.authorized) {
|
||||
expect(result2.reason).toBe(
|
||||
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
|
||||
const result3 = checkAuthorization(publicJwtEntityNoPermissions, "read", {
|
||||
tags: "tag_5678",
|
||||
});
|
||||
expect(result3.authorized).toBe(false);
|
||||
if (!result3.authorized) {
|
||||
expect(result3.reason).toBe(
|
||||
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle empty resource objects", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe("Resource object is empty");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle undefined scope", () => {
|
||||
const entityUndefinedPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" };
|
||||
const result = checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" });
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty scope array", () => {
|
||||
const entityEmptyPermissions: AuthorizationEntity = { type: "PUBLIC_JWT", scopes: [] };
|
||||
const result = checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" });
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return authorized if any resource is authorized", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_1234", // This is authorized
|
||||
tasks: "task_5678", // This is authorized (general permission)
|
||||
tags: "tag_3456", // This is not authorized
|
||||
});
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should return unauthorized only if no resources are authorized", () => {
|
||||
const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {
|
||||
runs: "run_5678", // Not authorized
|
||||
tags: "tag_3456", // Not authorized
|
||||
});
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toContain("Public Access Token is missing required permissions");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Super scope", () => {
|
||||
const entityWithSuperPermissions: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:all", "admin"],
|
||||
};
|
||||
|
||||
const entityWithOneSuperPermission: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:all"],
|
||||
};
|
||||
|
||||
it("should grant access with any of the super scope", () => {
|
||||
const result1 = checkAuthorization(
|
||||
entityWithSuperPermissions,
|
||||
"read",
|
||||
{ tasks: "task_1234" },
|
||||
["read:all", "admin"]
|
||||
);
|
||||
expect(result1.authorized).toBe(true);
|
||||
expect(result1).not.toHaveProperty("reason");
|
||||
|
||||
const result2 = checkAuthorization(
|
||||
entityWithSuperPermissions,
|
||||
"read",
|
||||
{ tags: ["tag_1", "tag_2"] },
|
||||
["write:all", "admin"]
|
||||
);
|
||||
expect(result2.authorized).toBe(true);
|
||||
expect(result2).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should grant access with one matching super permission", () => {
|
||||
const result = checkAuthorization(
|
||||
entityWithOneSuperPermission,
|
||||
"read",
|
||||
{ runs: "run_5678" },
|
||||
["read:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should not grant access when no super scope match", () => {
|
||||
const result = checkAuthorization(
|
||||
entityWithOneSuperPermission,
|
||||
"read",
|
||||
{ tasks: "task_1234" },
|
||||
["write:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should grant access to multiple resources with super scope", () => {
|
||||
const result = checkAuthorization(
|
||||
entityWithSuperPermissions,
|
||||
"read",
|
||||
{
|
||||
tasks: "task_1234",
|
||||
tags: ["tag_1", "tag_2"],
|
||||
runs: "run_5678",
|
||||
},
|
||||
["read:all"]
|
||||
);
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should fall back to specific scope when super scope are not provided", () => {
|
||||
const entityWithSpecificPermissions: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:tasks", "read:tags"],
|
||||
};
|
||||
const result1 = checkAuthorization(entityWithSpecificPermissions, "read", {
|
||||
tasks: "task_1234",
|
||||
});
|
||||
expect(result1.authorized).toBe(true);
|
||||
expect(result1).not.toHaveProperty("reason");
|
||||
|
||||
const result2 = checkAuthorization(entityWithSpecificPermissions, "read", {
|
||||
runs: "run_5678",
|
||||
});
|
||||
expect(result2.authorized).toBe(false);
|
||||
if (!result2.authorized) {
|
||||
expect(result2.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Query resource type", () => {
|
||||
it("should grant access with read:query super scope", () => {
|
||||
const entity: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:query"],
|
||||
};
|
||||
const result = checkAuthorization(
|
||||
entity,
|
||||
"read",
|
||||
{ query: "runs" },
|
||||
["read:query", "read:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(true);
|
||||
});
|
||||
|
||||
it("should grant access with table-specific query scope", () => {
|
||||
const entity: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:query:runs"],
|
||||
};
|
||||
const result = checkAuthorization(entity, "read", { query: "runs" });
|
||||
expect(result.authorized).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny access to different table with table-specific scope", () => {
|
||||
const entity: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:query:runs"],
|
||||
};
|
||||
const result = checkAuthorization(entity, "read", { query: "llm_metrics" });
|
||||
expect(result.authorized).toBe(false);
|
||||
});
|
||||
|
||||
it("should grant access with general read:query scope to any table", () => {
|
||||
const entity: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:query"],
|
||||
};
|
||||
|
||||
const runsResult = checkAuthorization(entity, "read", { query: "runs" });
|
||||
expect(runsResult.authorized).toBe(true);
|
||||
|
||||
const metricsResult = checkAuthorization(entity, "read", { query: "metrics" });
|
||||
expect(metricsResult.authorized).toBe(true);
|
||||
|
||||
const llmResult = checkAuthorization(entity, "read", { query: "llm_metrics" });
|
||||
expect(llmResult.authorized).toBe(true);
|
||||
});
|
||||
|
||||
it("should grant access to multiple tables when querying with super scope", () => {
|
||||
const entity: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:query"],
|
||||
};
|
||||
const result = checkAuthorization(
|
||||
entity,
|
||||
"read",
|
||||
{ query: ["runs", "llm_metrics"] },
|
||||
["read:query", "read:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(true);
|
||||
});
|
||||
|
||||
it("should grant access to schema with read:query scope", () => {
|
||||
const entity: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:query"],
|
||||
};
|
||||
const result = checkAuthorization(
|
||||
entity,
|
||||
"read",
|
||||
{ query: "schema" },
|
||||
["read:query", "read:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Without super scope", () => {
|
||||
const entityWithoutSuperPermissions: AuthorizationEntity = {
|
||||
type: "PUBLIC_JWT",
|
||||
scopes: ["read:tasks"],
|
||||
};
|
||||
|
||||
it("should still grant access based on specific scope", () => {
|
||||
const result = checkAuthorization(
|
||||
entityWithoutSuperPermissions,
|
||||
"read",
|
||||
{ tasks: "task_1234" },
|
||||
["read:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(true);
|
||||
expect(result).not.toHaveProperty("reason");
|
||||
});
|
||||
|
||||
it("should deny access to resources not in scope", () => {
|
||||
const result = checkAuthorization(
|
||||
entityWithoutSuperPermissions,
|
||||
"read",
|
||||
{ runs: "run_5678" },
|
||||
["read:all", "admin"]
|
||||
);
|
||||
expect(result.authorized).toBe(false);
|
||||
if (!result.authorized) {
|
||||
expect(result.reason).toBe(
|
||||
"Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information."
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// Inserts a `Session` row directly via Prisma so route auth tests can
|
||||
// exercise routes that resolve a session by friendlyId or externalId.
|
||||
//
|
||||
// Note: not to be confused with `seedTestSession` in this directory —
|
||||
// that helper builds a *dashboard cookie session* for cookie-auth tests.
|
||||
// This helper builds an *agent-stream Session row* (the chat.agent
|
||||
// runtime concept).
|
||||
|
||||
import type { PrismaClient, Session } from "@trigger.dev/database";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
function randomHex(len = 12): string {
|
||||
return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len);
|
||||
}
|
||||
|
||||
export async function seedTestApiSession(
|
||||
prisma: PrismaClient,
|
||||
env: {
|
||||
id: string;
|
||||
type: string;
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
},
|
||||
overrides?: { taskIdentifier?: string; externalId?: string | null }
|
||||
): Promise<Session> {
|
||||
const suffix = randomHex(8);
|
||||
return prisma.session.create({
|
||||
data: {
|
||||
id: `session_${suffix}`,
|
||||
friendlyId: `session_${suffix}`,
|
||||
// `null` lets a caller exercise the externalId-absent code path
|
||||
// (single-id auth resource); omit the override to get a unique
|
||||
// externalId for the multi-key path.
|
||||
externalId:
|
||||
overrides?.externalId === null
|
||||
? null
|
||||
: overrides?.externalId ?? `ext_${suffix}`,
|
||||
type: "chat.agent",
|
||||
projectId: env.projectId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
environmentType: env.type as Session["environmentType"],
|
||||
organizationId: env.organizationId,
|
||||
taskIdentifier: overrides?.taskIdentifier ?? `agent_${suffix}`,
|
||||
triggerConfig: { basePayload: { messages: [], trigger: "preload" } },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<SeededRun> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<string> {
|
||||
const session = await sessionStorage.getSession();
|
||||
session.set("user", { userId: opts.userId });
|
||||
const setCookie = await sessionStorage.commitSession(session);
|
||||
// commitSession returns "__session=<value>; Path=/; ...". The Cookie
|
||||
// header only needs the name=value pair.
|
||||
const firstSegment = setCookie.split(";")[0];
|
||||
return firstSegment;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user