Skip to content

Commit 7fd671a

Browse files
d-csclaudecarderne
authored
fix(webapp): schedule & env-var write scoping (reject cross-project/env IDs) (#46)
* fix(webapp): scope schedule lookups by project and environment (TRI-9865) Closes a cluster of cross-tenant IDORs in the schedule routes and services by scoping every `findFirst({friendlyId})` to the caller's projectId, and by gating the env-scoped schedule API on env-visibility so a low-trust key (e.g. dev) can't see or mutate a schedule whose instances live in a different env. Foreign environment IDs passed to CheckScheduleService now fail closed instead of being silently filtered. Three observable API changes are documented in .server-changes/tri-9865-tenant-isolation-schedules.md. Closes TRI-9865, TRI-10040, TRI-10041 (P0); TRI-9854, TRI-9869, TRI-9960, TRI-9963, TRI-9997 (P1); TRI-9859 (P2). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(webapp): consolidate schedule env-visibility into tri-state helper Address review feedback on the TRI-9865 batch: - Promote findScheduleScopedToEnvironment to a tri-state helper getScheduleEnvVisibility returning { status: 'visible' | 'hidden' | 'missing' }. PUT can disambiguate hidden (refuse, 404) from missing (fall through to upsert's create path) without re-implementing the visibility logic inline. - All three call sites (GET / PUT / DELETE in api.v1.schedules.$id) now share the single helper. - Add test coverage for the deduplicationKey branch of scheduleWhereClause (covered the sched_-prefix branch only before). - Add DeleteTaskScheduleService test asserting DECLARATIVE schedules cannot be deleted (surfaces as a service-layer error after the visibility check passes). - Rename the misleading "devEnv" test fixture to "stagingEnv" to match its actual RuntimeEnvironment type. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(webapp): regression tests for schedule environment scoping Scopes schedule read/update/delete/activate to the caller's environment via a shared getScheduleEnvVisibility (visible/hidden/missing) helper. Real-Postgres tests (testcontainers) cover checkSchedule, delete, setActive, and PUT upsert. Verified RED on the shared visibility guard (cross-env detection flips); GREEN 13/13. Bundles the streamBatchItems timeout bump. * fix(webapp): reject foreign environment IDs when upserting a schedule CheckScheduleService.call previously intersected `project.environments` with the caller-supplied `environmentIds` via `.filter()`, silently dropping any ID that didn't belong to the authorized project. Downstream UpsertTaskScheduleService.#createNewSchedule then iterated the raw input and created TaskScheduleInstance rows pairing the validated projectId with whichever environmentId the caller sent — including another tenant's RuntimeEnvironment. When the schedule fires, the engine triggers against the *referenced* environment, enabling cross-tenant task execution if the victim env id is known. Replace the silent intersection with an explicit rejection: build a map of project.environments by id and throw `ServiceValidationError` on the first foreign id, so #createNewSchedule and #updateExistingSchedule never see an environmentId outside the authorized project. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(repro): remove reproducer from PR branch * test(webapp): regression test for cross-project schedule env scoping Extracts the env-scoping into a pure resolveProjectScopedEnvironments (returns a foreign-id flag or the mapped envs) and unit-tests it: all-valid resolves; foreign id rejected; foreign mixed with valid still rejected (not dropped); empty ok. Verified RED against the pre-fix silent filter-drop, GREEN with the rejection. Bundles the streamBatchItems timeout bump. * fix(webapp): enforce per-env access when creating env vars The env-vars `new` action passed user-supplied `environmentIds[]` straight to `repository.create` after a project-membership check. DEV environments are per-user (`RuntimeEnvironment.orgMember.userId`), and the dashboard loader filters other members' DEV envs out of the UI — but the action did not enforce the same filter. A project member could submit `environmentIds=[my_dev, victim_dev]` plus a key/value, and the value was injected into the victim's next task run via `resolveVariablesForEnvironment` (whose secret-store key prefix is `environmentvariable:<projectId>:<envId>:`). Now query `runtimeEnvironment.findMany` with the same OR clause `findEnvironmentBySlug` uses — non-DEV envs in the project, or DEV envs owned by the requesting user — and refuse the submission if any submitted ID is missing from the result. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(repro): remove reproducer from PR branch * test(webapp): regression test for env-var DEV-environment ownership Extracts the per-env writability rule into a pure findUnauthorizedEnvironmentId (shared env types writable by any member; DEV only by its owner; unknown id rejected) and unit-tests it. Route now fetches the candidate envs and applies the helper. Verified RED against the pre-fix no-check behaviour, GREEN with it. Bundles the streamBatchItems timeout bump. * fix(webapp): reject mixed valid/foreign environmentIds in env-var create/edit The create()/edit() guard used `environmentIds.every((v) => !inProject(v))`, which only errors when EVERY id is foreign — a single in-project id short-circuited it, so a mixed array passed and the write loop stored values/secrets against another tenant's environment. Switch to `.some` so any foreign id rejects the whole request. Adds RED→GREEN cross-tenant regression tests and bumps the streamBatchItems CI timeout. * test(webapp): isolate schedule scoping tests from the global prisma singleton The checkSchedule/deleteTaskSchedule/setActiveOnTaskSchedule regression tests imported the full services, which pull in ~/db.server; its eager global-prisma $connect() becomes an unhandled rejection in a unit-test job with no reachable global database, failing vitest even though every test passes. Make schedules.server a leaf (type-only Prisma imports) and rewrite the three tests to exercise the project/environment scoping primitives directly against the container DB (scheduleWhereClause, scheduleUniqWhereClause, resolveProjectScopedEnvironments) instead of importing the services. * fix(webapp): enforce DEV-env ownership on env-var edit and delete The per-user DEV-env write check was only on the create route; the edit/delete value actions passed a user-controlled environmentId straight to the repository, which only checks project membership — letting a member overwrite or delete an env-var value in another member's DEV environment. Gate both actions with the same findUnauthorizedEnvironmentId check the create route uses. * format * chore: consolidate server-change and tighten comments * fix(webapp): use .some for schedule env visibility and gate activate/deactivate A schedule can be bound to several environments at once, and the schedule list surfaces a schedule for any environment it has an instance in. The per-schedule visibility check required every instance to be in the caller's environment, so a multi-environment schedule was listed but returned 404 on GET/PUT/DELETE for the same key. Match the list's semantics: a schedule is visible when it has at least one instance in the caller's environment. Also apply the same environment-visibility gate to the activate and deactivate endpoints, which previously scoped only by project and let a key scoped to one environment enable/disable a schedule that runs only in another environment of the same project. * fix(webapp): reject empty-string foreign environment id instead of dropping it The foreign-id guard used a truthiness check, so an empty-string environment id (a foreign id) was falsy and fell through to the accepted path, where it was silently dropped rather than rejected. Use an explicit undefined check so any foreign id, including the empty string, is reported and rejected. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Chris Arderne <chris@trigger.dev>
1 parent 677ce4c commit 7fd671a

21 files changed

Lines changed: 962 additions & 38 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Scope schedule and environment-variable writes to the caller's project and environment

apps/webapp/app/models/schedules.server.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Prisma } from "~/db.server";
1+
import type { Prisma, PrismaClientOrTransaction, TaskSchedule } from "@trigger.dev/database";
22

33
export function scheduleUniqWhereClause(
44
projectId: string,
@@ -35,3 +35,48 @@ export function scheduleWhereClause(
3535
deduplicationKey: scheduleId,
3636
};
3737
}
38+
39+
/**
40+
* Resolve a schedule's visibility for an environment-scoped caller.
41+
*
42+
* - "visible": the schedule exists in the project and has at least one
43+
* instance bound to `environmentId` (or has no instances yet).
44+
* - "hidden": the schedule exists but none of its instances live in the
45+
* caller's environment.
46+
* - "missing": no schedule exists for the (project, scheduleId) pair.
47+
*
48+
* A schedule can be bound to several environments at once, so visibility
49+
* mirrors the "some instance is in this environment" rule the schedule
50+
* list uses: a schedule that is listed for a key must also be readable
51+
* and mutable by that key. This still rejects cross-environment access to
52+
* schedules the caller has no instance in, and `scheduleWhereClause`
53+
* already confines the lookup to the caller's project.
54+
*
55+
* The tri-state lets PUT (upsert) disambiguate "hidden" (refuse) from
56+
* "missing" (fall through to create). DELETE/GET treat hidden and
57+
* missing the same way.
58+
*/
59+
export type ScheduleEnvVisibility =
60+
| { status: "visible"; schedule: TaskSchedule }
61+
| { status: "hidden" }
62+
| { status: "missing" };
63+
64+
export async function getScheduleEnvVisibility(
65+
prisma: PrismaClientOrTransaction,
66+
projectId: string,
67+
scheduleId: string,
68+
environmentId: string
69+
): Promise<ScheduleEnvVisibility> {
70+
const schedule = await prisma.taskSchedule.findFirst({
71+
where: scheduleWhereClause(projectId, scheduleId),
72+
include: { instances: { select: { environmentId: true } } },
73+
});
74+
75+
if (!schedule) return { status: "missing" };
76+
77+
const { instances, ...rest } = schedule;
78+
if (instances.length === 0) return { status: "visible", schedule: rest };
79+
const scoped = instances.some((i) => i.environmentId === environmentId);
80+
if (!scoped) return { status: "hidden" };
81+
return { status: "visible", schedule: rest };
82+
}

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables.new/route.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
} from "~/utils/pathBuilder";
5656
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
5757
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
58+
import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments";
5859

5960
const Variable = z.object({
6061
key: EnvironmentVariableKey,
@@ -164,6 +165,31 @@ export const action = dashboardAction(
164165
return json(submission.reply({ formErrors: ["Project not found"] }));
165166
}
166167

168+
// The submitted `environmentIds` are user-supplied. Shared env types are
169+
// writable by any member; a DEV env only by its owner. See
170+
// findUnauthorizedEnvironmentId.
171+
const submittedEnvs = await prisma.runtimeEnvironment.findMany({
172+
where: {
173+
projectId: project.id,
174+
id: { in: submission.value.environmentIds },
175+
},
176+
select: { id: true, type: true, orgMember: { select: { userId: true } } },
177+
});
178+
const unauthorizedEnvironmentId = findUnauthorizedEnvironmentId(
179+
submittedEnvs,
180+
submission.value.environmentIds,
181+
userId
182+
);
183+
if (unauthorizedEnvironmentId) {
184+
return json(
185+
submission.reply({
186+
fieldErrors: {
187+
environmentIds: ["One or more of the selected environments is not writable by you."],
188+
},
189+
})
190+
);
191+
}
192+
167193
const repository = new EnvironmentVariablesRepository(prisma);
168194
const result = await repository.create(project.id, {
169195
...submission.value,

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.environment-variables/route.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import {
7878
v3NewEnvironmentVariablesPath,
7979
} from "~/utils/pathBuilder";
8080
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
81+
import { findUnauthorizedEnvironmentId } from "~/v3/writableEnvironments";
8182
import {
8283
DeleteEnvironmentVariableValue,
8384
EditEnvironmentVariableValue,
@@ -267,6 +268,24 @@ export const action = dashboardAction(
267268
return json(submission.reply({ formErrors: ["Project not found"] }));
268269
}
269270

271+
// Per-env write gate for the mutating value actions: `environmentId` is a
272+
// user-supplied hidden field and the repository only checks project
273+
// membership. Mirrors the create route's check.
274+
if (submission.value.action === "edit" || submission.value.action === "delete") {
275+
const submittedEnvs = await prisma.runtimeEnvironment.findMany({
276+
where: { projectId: project.id, id: submission.value.environmentId },
277+
select: { id: true, type: true, orgMember: { select: { userId: true } } },
278+
});
279+
const unauthorizedEnvironmentId = findUnauthorizedEnvironmentId(
280+
submittedEnvs,
281+
[submission.value.environmentId],
282+
userId
283+
);
284+
if (unauthorizedEnvironmentId) {
285+
return json(submission.reply({ formErrors: ["This environment is not writable by you."] }));
286+
}
287+
}
288+
270289
switch (submission.value.action) {
271290
case "edit": {
272291
const repository = new EnvironmentVariablesRepository(prisma);

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam/route.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { z } from "zod";
66
import { ExitIcon } from "~/assets/icons/ExitIcon";
77
import { LinkButton } from "~/components/primitives/Buttons";
88
import { ScheduleInspector } from "~/components/schedules/ScheduleInspector";
9-
import { prisma } from "~/db.server";
109
import { useEnvironment } from "~/hooks/useEnvironment";
1110
import { useOrganization } from "~/hooks/useOrganizations";
1211
import { useProject } from "~/hooks/useProject";
@@ -78,11 +77,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
7877
// `_format=json` → return JSON instead of redirecting; caller stays put.
7978
const wantsJson = formData.get("_format") === "json";
8079

81-
const project = await prisma.project.findFirst({
82-
where: {
83-
slug: projectParam,
84-
},
85-
});
80+
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
8681

8782
if (!project) {
8883
const message = `No project found with slug ${projectParam}`;

apps/webapp/app/routes/api.v1.schedules.$scheduleId.activate.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
5-
import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server";
5+
import { getScheduleEnvVisibility, scheduleUniqWhereClause } from "~/models/schedules.server";
66
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
77
import { authenticateApiRequest } from "~/services/apiAuth.server";
88
import { logger } from "~/services/logger.server";
@@ -34,14 +34,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
3434
}
3535

3636
try {
37-
const existingSchedule = await prisma.taskSchedule.findFirst({
38-
where: scheduleWhereClause(
39-
authenticationResult.environment.projectId,
40-
parsedParams.data.scheduleId
41-
),
42-
});
43-
44-
if (!existingSchedule) {
37+
// Env-scoped API keys can only toggle schedules that have an instance in
38+
// their own environment. Without this a key scoped to one environment
39+
// could enable/disable a schedule that only runs in another environment
40+
// of the same project.
41+
const visibility = await getScheduleEnvVisibility(
42+
prisma,
43+
authenticationResult.environment.projectId,
44+
parsedParams.data.scheduleId,
45+
authenticationResult.environment.id
46+
);
47+
if (visibility.status !== "visible") {
4548
return json({ error: "Schedule not found" }, { status: 404 });
4649
}
4750

apps/webapp/app/routes/api.v1.schedules.$scheduleId.deactivate.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { prisma } from "~/db.server";
5-
import { scheduleUniqWhereClause, scheduleWhereClause } from "~/models/schedules.server";
5+
import { getScheduleEnvVisibility, scheduleUniqWhereClause } from "~/models/schedules.server";
66
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
77
import { authenticateApiRequest } from "~/services/apiAuth.server";
88
import { logger } from "~/services/logger.server";
@@ -34,14 +34,17 @@ export async function action({ request, params }: ActionFunctionArgs) {
3434
}
3535

3636
try {
37-
const existingSchedule = await prisma.taskSchedule.findFirst({
38-
where: scheduleWhereClause(
39-
authenticationResult.environment.projectId,
40-
parsedParams.data.scheduleId
41-
),
42-
});
43-
44-
if (!existingSchedule) {
37+
// Env-scoped API keys can only toggle schedules that have an instance in
38+
// their own environment. Without this a key scoped to one environment
39+
// could enable/disable a schedule that only runs in another environment
40+
// of the same project.
41+
const visibility = await getScheduleEnvVisibility(
42+
prisma,
43+
authenticationResult.environment.projectId,
44+
parsedParams.data.scheduleId,
45+
authenticationResult.environment.id
46+
);
47+
if (visibility.status !== "visible") {
4548
return json({ error: "Schedule not found" }, { status: 404 });
4649
}
4750

apps/webapp/app/routes/api.v1.schedules.$scheduleId.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { UpdateScheduleOptions } from "@trigger.dev/core/v3";
55
import { z } from "zod";
66
import { Prisma, prisma } from "~/db.server";
77
import { clientSafeErrorMessage } from "~/utils/prismaErrors";
8-
import { scheduleUniqWhereClause } from "~/models/schedules.server";
8+
import { getScheduleEnvVisibility, scheduleUniqWhereClause } from "~/models/schedules.server";
99
import { ViewSchedulePresenter } from "~/presenters/v3/ViewSchedulePresenter.server";
1010
import { authenticateApiRequest } from "~/services/apiAuth.server";
1111
import { logger } from "~/services/logger.server";
@@ -38,6 +38,16 @@ export async function action({ request, params }: ActionFunctionArgs) {
3838

3939
switch (method) {
4040
case "DELETE": {
41+
const visibility = await getScheduleEnvVisibility(
42+
prisma,
43+
authenticationResult.environment.projectId,
44+
parsedParams.data.scheduleId,
45+
authenticationResult.environment.id
46+
);
47+
if (visibility.status !== "visible") {
48+
return json({ error: "Schedule not found" }, { status: 404 });
49+
}
50+
4151
try {
4252
const deletedSchedule = await prisma.taskSchedule.delete({
4353
where: scheduleUniqWhereClause(
@@ -76,6 +86,19 @@ export async function action({ request, params }: ActionFunctionArgs) {
7686
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
7787
}
7888

89+
// Env-scoped API keys can't see or mutate a schedule whose
90+
// instances live in a different environment. "hidden" → refuse;
91+
// "missing" → fall through to the upsert's create path.
92+
const visibility = await getScheduleEnvVisibility(
93+
prisma,
94+
authenticationResult.environment.projectId,
95+
parsedParams.data.scheduleId,
96+
authenticationResult.environment.id
97+
);
98+
if (visibility.status === "hidden") {
99+
return json({ error: "Schedule not found" }, { status: 404 });
100+
}
101+
79102
const service = new UpsertTaskScheduleService();
80103

81104
try {
@@ -137,6 +160,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
137160
);
138161
}
139162

163+
const visibility = await getScheduleEnvVisibility(
164+
prisma,
165+
authenticationResult.environment.projectId,
166+
parsedParams.data.scheduleId,
167+
authenticationResult.environment.id
168+
);
169+
if (visibility.status !== "visible") {
170+
return json({ error: "Schedule not found" }, { status: 404 });
171+
}
172+
140173
const presenter = new ViewSchedulePresenter();
141174

142175
const result = await presenter.call({

apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ export class EnvironmentVariablesRepository implements Repository {
8282
return { success: false as const, error: "Project not found" };
8383
}
8484

85-
if (options.environmentIds.every((v) => !project.environments.some((e) => e.id === v))) {
85+
// Reject if ANY supplied environmentId is outside the caller's project.
86+
// `.some` (not `.every`) so one in-project id can't let a mixed array
87+
// through.
88+
if (options.environmentIds.some((v) => !project.environments.some((e) => e.id === v))) {
8689
return { success: false as const, error: `Environment not found` };
8790
}
8891

@@ -291,7 +294,9 @@ export class EnvironmentVariablesRepository implements Repository {
291294
return { success: false as const, error: "Project not found" };
292295
}
293296

294-
if (options.values.every((v) => !project.environments.some((e) => e.id === v.environmentId))) {
297+
// Same guard as `create()`: reject if ANY supplied environmentId is
298+
// outside the caller's project (`.some`, not `.every`).
299+
if (options.values.some((v) => !project.environments.some((e) => e.id === v.environmentId))) {
295300
return { success: false as const, error: `Environment not found` };
296301
}
297302

apps/webapp/app/v3/services/checkSchedule.server.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ZodError } from "zod";
22
import { CronPattern } from "../schedules";
33
import { BaseService, ServiceValidationError } from "./baseService.server";
4+
import { resolveProjectScopedEnvironments } from "./resolveProjectScopedEnvironments";
45
import { getLimit } from "~/services/platform.v3.server";
56
import { getTimezones } from "~/utils/timezones.server";
67
import { env } from "~/env.server";
@@ -82,7 +83,19 @@ export class CheckScheduleService extends BaseService {
8283
throw new ServiceValidationError("Project not found");
8384
}
8485

85-
const environments = project.environments.filter((env) => environmentIds.includes(env.id));
86+
// Reject (don't silently drop) any environmentId that doesn't belong to the
87+
// authorized project.
88+
const scopedEnvironments = resolveProjectScopedEnvironments(
89+
environmentIds,
90+
project.environments
91+
);
92+
if (scopedEnvironments.kind === "foreign") {
93+
throw new ServiceValidationError(
94+
`Environment ${scopedEnvironments.foreignEnvironmentId} does not belong to this project.`
95+
);
96+
}
97+
98+
const environments = scopedEnvironments.environments;
8699
if (environments.some((env) => env.archivedAt)) {
87100
throw new ServiceValidationError("Can't add or edit a schedule for an archived branch");
88101
}

0 commit comments

Comments
 (0)