e4981d1b11
## 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>
428 lines
17 KiB
TypeScript
428 lines
17 KiB
TypeScript
/**
|
|
* E2E auth baseline tests.
|
|
*
|
|
* These tests capture current auth behavior before the apiBuilder migration to RBAC.
|
|
* Run them before and after the migration to verify behavior is identical.
|
|
*
|
|
* Requires a pre-built webapp: pnpm run build --filter webapp
|
|
*/
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
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 });
|
|
|
|
// Shared across all tests in this file — one postgres container + one webapp instance.
|
|
let server: TestServer;
|
|
|
|
beforeAll(async () => {
|
|
server = await startTestServer();
|
|
}, 180_000);
|
|
|
|
afterAll(async () => {
|
|
await server?.stop();
|
|
}, 120_000);
|
|
|
|
async function generateTestJWT(
|
|
environment: { id: string; apiKey: string },
|
|
options: { scopes?: string[] } = {}
|
|
): Promise<string> {
|
|
const scopes = options.scopes ?? ["read:runs"];
|
|
return generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
}
|
|
|
|
describe("API bearer auth — baseline behavior", () => {
|
|
it("valid API key: auth passes (404 not 401)", async () => {
|
|
const { apiKey } = await seedTestEnvironment(server.prisma);
|
|
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
});
|
|
// Auth passed — resource just doesn't exist
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("missing Authorization header: 401", async () => {
|
|
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("invalid API key: 401", async () => {
|
|
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
|
|
headers: { Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real" },
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("401 response has error field", async () => {
|
|
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result");
|
|
const body = await res.json();
|
|
expect(body).toHaveProperty("error");
|
|
});
|
|
});
|
|
|
|
describe("JWT bearer auth — baseline behavior", () => {
|
|
it("valid JWT on JWT-enabled route: auth passes", async () => {
|
|
const { environment } = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
|
|
|
|
// /api/v1/runs has allowJWT: true with superScopes: ["read:runs", ...]
|
|
const res = await server.webapp.fetch("/api/v1/runs", {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
|
|
// Auth passed — 200 (empty list) or 400 (bad search params), not 401
|
|
expect(res.status).not.toBe(401);
|
|
});
|
|
|
|
it("valid JWT on non-JWT route: 401", async () => {
|
|
const { environment } = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] });
|
|
|
|
// /api/v1/runs/$runParam/result does NOT have allowJWT: true
|
|
const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT with empty scopes on JWT-enabled route: 403", async () => {
|
|
const { environment } = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateTestJWT(environment, { scopes: [] });
|
|
|
|
const res = await server.webapp.fetch("/api/v1/runs", {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
|
|
// Empty scopes → no read:runs permission → 403
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT signed with wrong key: 401", async () => {
|
|
const { environment } = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: "wrong-signing-key-that-does-not-match-environment-key",
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
|
|
});
|
|
|
|
const res = await server.webapp.fetch("/api/v1/runs", {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|