Skip to content

Commit 7c39f65

Browse files
authored
Merge branch 'main' into docs-ai-agents-chat-agent-guide
2 parents 3928779 + 0a44b88 commit 7c39f65

17 files changed

Lines changed: 544 additions & 29 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: improvement
4+
---
5+
6+
Lower background database load during deployments and dev sessions for projects that use declarative schedules.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Creating or updating a schedule, and deploying a project with declarative schedules, is faster in long-lived projects with many deploys.
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+
Reject alert webhook destinations in reserved benchmarking IP ranges.

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

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Prisma as PrismaNamespace,
1616
prisma,
1717
type PrismaClientOrTransaction,
18+
type PrismaReplicaClient,
1819
} from "~/db.server";
1920
import { env } from "~/env.server";
2021
import { featuresForUrl } from "~/features.server";
@@ -41,22 +42,49 @@ const nanoid = customAlphabet("1234567890abcdef", 4);
4142
* miss, so replica lag never leaves a real org unresolved, which the dashboard
4243
* route builder treats as an unauthorized request.
4344
*/
44-
export async function resolveOrgIdFromSlug(slug: string): Promise<string | null> {
45-
const fromReplica = await $replica.organization.findFirst({
45+
export async function resolveOrgIdFromSlug(
46+
slug: string,
47+
replicaClient: PrismaReplicaClient = $replica,
48+
prismaClient: PrismaClientOrTransaction = prisma
49+
): Promise<string | null> {
50+
const fromReplica = await replicaClient.organization.findFirst({
4651
where: { slug },
4752
select: { id: true },
4853
});
4954
if (fromReplica) {
5055
return fromReplica.id;
5156
}
5257

53-
const fromPrimary = await prisma.organization.findFirst({
58+
const fromPrimary = await prismaClient.organization.findFirst({
5459
where: { slug },
5560
select: { id: true },
5661
});
5762
return fromPrimary?.id ?? null;
5863
}
5964

65+
/**
66+
* Like `resolveOrgIdFromSlug`, but only resolves an org the user is a member of. `ability.can` is not
67+
* a tenant floor (the OSS fallback and the cloud plugin both return a permissive ability for a
68+
* non-member), so a route that scopes only by slug lets a non-member reach the handler; the
69+
* membership filter here is the tenant floor. Returns null for a non-member, which the dashboard
70+
* route builder treats as no scope and fails closed.
71+
*/
72+
export async function resolveOrgIdFromSlugForUser(
73+
slug: string,
74+
userId: string,
75+
replicaClient: PrismaReplicaClient = $replica,
76+
prismaClient: PrismaClientOrTransaction = prisma
77+
): Promise<string | null> {
78+
const where = { slug, members: { some: { userId } } };
79+
const fromReplica = await replicaClient.organization.findFirst({ where, select: { id: true } });
80+
if (fromReplica) {
81+
return fromReplica.id;
82+
}
83+
84+
const fromPrimary = await prismaClient.organization.findFirst({ where, select: { id: true } });
85+
return fromPrimary?.id ?? null;
86+
}
87+
6088
export async function createOrganization(
6189
{
6290
title,

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.sso/route.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { Select, SelectItem } from "~/components/primitives/Select";
3636
import { Switch } from "~/components/primitives/Switch";
3737
import { prisma } from "~/db.server";
38+
import { getUserId } from "~/services/session.server";
3839
import { useOrganization } from "~/hooks/useOrganizations";
3940
import { rbac } from "~/services/rbac.server";
4041
import { ssoController } from "~/services/sso.server";
@@ -53,11 +54,14 @@ export const meta = pageMeta("SSO & Directory Sync");
5354

5455
const Params = z.object({ organizationSlug: z.string() });
5556

56-
async function resolveOrg(slug: string) {
57+
async function resolveOrg(slug: string, userId: string) {
58+
// Scoped to membership: ability.can is not a tenant floor (the cloud RBAC
59+
// plugin returns a permissive ability for a non-member), so without the
60+
// members filter a non-member reaches the handler for any org slug.
5761
// Primary (not replica): this scopes the RBAC/entitlement checks, so lag
5862
// could run them against a stale/missing org.
5963
return prisma.organization.findFirst({
60-
where: { slug },
64+
where: { slug, members: { some: { userId } } },
6165
select: { id: true, title: true },
6266
});
6367
}
@@ -117,8 +121,10 @@ const EMPTY_SSO_STATUS = {
117121
export const loader = dashboardLoader(
118122
{
119123
params: Params,
120-
context: async (params) => {
121-
const org = await resolveOrg(params.organizationSlug);
124+
context: async (params, request) => {
125+
const userId = await getUserId(request);
126+
if (!userId) return {};
127+
const org = await resolveOrg(params.organizationSlug, userId);
122128
return org ? { organizationId: org.id, orgTitle: org.title } : {};
123129
},
124130
// Plan-gated before role-gated: non-Enterprise orgs render the upsell for
@@ -211,8 +217,10 @@ const ActionSchema = z.discriminatedUnion("action", [
211217
export const action = dashboardAction(
212218
{
213219
params: Params,
214-
context: async (params) => {
215-
const org = await resolveOrg(params.organizationSlug);
220+
context: async (params, request) => {
221+
const userId = await getUserId(request);
222+
if (!userId) return {};
223+
const org = await resolveOrg(params.organizationSlug, userId);
216224
return org ? { organizationId: org.id } : {};
217225
},
218226
authorization: { action: "manage", resource: { type: "sso" } },

apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ import { useOrganization } from "~/hooks/useOrganizations";
3737
import { useUser } from "~/hooks/useUser";
3838
import { removeTeamMember } from "~/models/removeTeamMember.server";
3939
import { redirectWithSuccessMessage } from "~/models/message.server";
40-
import { resolveOrgIdFromSlug } from "~/models/organization.server";
40+
import { resolveOrgIdFromSlugForUser } from "~/models/organization.server";
41+
import { getUserId } from "~/services/session.server";
4142
import { TeamPresenter } from "~/presenters/TeamPresenter.server";
4243
import { getCurrentPlan, getSelfServePurchaseBlockReason } from "~/services/platform.v3.server";
4344
import { rbac } from "~/services/rbac.server";
@@ -66,8 +67,10 @@ const Params = z.object({
6667
export const loader = dashboardLoader(
6768
{
6869
params: Params,
69-
context: async (params) => {
70-
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
70+
context: async (params, request) => {
71+
const userId = await getUserId(request);
72+
if (!userId) return {};
73+
const orgId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
7174
return orgId ? { organizationId: orgId } : {};
7275
},
7376
authorization: { action: "read", resource: { type: "members" } },
@@ -127,8 +130,10 @@ const SetRoleSchema = z.object({
127130
export const action = dashboardAction(
128131
{
129132
params: Params,
130-
context: async (params) => {
131-
const orgId = await resolveOrgIdFromSlug(params.organizationSlug);
133+
context: async (params, request) => {
134+
const userId = await getUserId(request);
135+
if (!userId) return {};
136+
const orgId = await resolveOrgIdFromSlugForUser(params.organizationSlug, userId);
132137
return orgId ? { organizationId: orgId } : {};
133138
},
134139
// No top-level authorization — different intents have different

apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ function isUnsafeIPv4(host: string): boolean {
4242
if (a === 169 && b === 254) return true;
4343
// 100.64/10 carrier-grade NAT
4444
if (a === 100 && b >= 64 && b <= 127) return true;
45+
// 198.18/15 benchmarking
46+
if (a === 198 && b >= 18 && b <= 19) return true;
4547
// 224/4 multicast
4648
if (a >= 224 && a <= 239) return true;
4749
// 240/4 reserved

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export class CheckScheduleService extends BaseService {
4545
slug: schedule.taskIdentifier,
4646
projectId: projectId,
4747
},
48+
select: {
49+
triggerSource: true,
50+
},
4851
orderBy: {
4952
createdAt: "desc",
5053
},

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

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -775,27 +775,40 @@ export async function syncDeclarativeSchedules(
775775
},
776776
});
777777

778+
const scheduleIdsToDelete: string[] = [];
779+
const scheduleIdsToDetachFromEnvironment: string[] = [];
780+
778781
for (const schedule of potentiallyDeletableSchedules) {
779782
const canDeleteSchedule =
780783
schedule.instances.length === 0 ||
781784
schedule.instances.every((instance) => instance.environmentId === environment.id);
782785

783786
if (canDeleteSchedule) {
784-
//we can delete schedules with no instances other than ones for the current environment
785-
await prisma.taskSchedule.delete({
786-
where: {
787-
id: schedule.id,
787+
scheduleIdsToDelete.push(schedule.id);
788+
} else if (schedule.instances.some((instance) => instance.environmentId === environment.id)) {
789+
scheduleIdsToDetachFromEnvironment.push(schedule.id);
790+
}
791+
}
792+
793+
if (scheduleIdsToDelete.length > 0) {
794+
await prisma.taskSchedule.deleteMany({
795+
where: {
796+
id: {
797+
in: scheduleIdsToDelete,
788798
},
789-
});
790-
} else {
791-
//otherwise we delete the instance (other environments remain untouched)
792-
await prisma.taskScheduleInstance.deleteMany({
793-
where: {
794-
taskScheduleId: schedule.id,
795-
environmentId: environment.id,
799+
},
800+
});
801+
}
802+
803+
if (scheduleIdsToDetachFromEnvironment.length > 0) {
804+
await prisma.taskScheduleInstance.deleteMany({
805+
where: {
806+
taskScheduleId: {
807+
in: scheduleIdsToDetachFromEnvironment,
796808
},
797-
});
798-
}
809+
environmentId: environment.id,
810+
},
811+
});
799812
}
800813
}
801814

apps/webapp/test/auth-dashboard.e2e.full.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Each test seeds a User + session cookie via seedTestUser / seedTestSession
33
// (helpers/seedTestSession.ts) and hits the shared webapp container.
44

5+
import { randomBytes } from "node:crypto";
6+
import type { PrismaClient } from "@trigger.dev/database";
57
import { describe, expect, it } from "vitest";
68
import { getTestServer } from "./helpers/sharedTestServer";
79
import { seedTestSession, seedTestUser } from "./helpers/seedTestSession";
@@ -115,4 +117,66 @@ describe("Dashboard", () => {
115117
expect(new URL(location, "http://localhost").pathname).toBe("/");
116118
});
117119
});
120+
121+
// Cross-tenant tenant floor on org settings routes. settings/roles is the case
122+
// the route-level membership scoping (SSO/Team) did NOT cover, so it exercises
123+
// the RBAC fallback's org-membership floor specifically: the fallback ability
124+
// is permissive (can: () => true), so that floor is the only thing stopping a
125+
// non-member from reading the org's role and permission catalogue.
126+
//
127+
// The request hits the route's own loader directly via Remix's `?_data`, which
128+
// is the exact exploit shape: a plain document GET 404s at the org layout
129+
// (membership) and never reaches this leaf, so it wouldn't test the leaf floor.
130+
// Both users have confirmedBasicDetails set so the `_app` onboarding redirect
131+
// can't stand in for the deny.
132+
describe("Org settings — cross-tenant tenant floor (settings/roles)", () => {
133+
const ROLES_ROUTE_ID = "routes/_app.orgs.$organizationSlug.settings.roles";
134+
const rolesData = (slug: string) =>
135+
`/orgs/${slug}/settings/roles?_data=${encodeURIComponent(ROLES_ROUTE_ID)}`;
136+
137+
async function seedConfirmedUser(prisma: PrismaClient) {
138+
const user = await seedTestUser(prisma);
139+
await prisma.user.update({ where: { id: user.id }, data: { confirmedBasicDetails: true } });
140+
return user;
141+
}
142+
143+
async function seedOrgWithOwner() {
144+
const server = getTestServer();
145+
const owner = await seedConfirmedUser(server.prisma);
146+
const org = await server.prisma.organization.create({
147+
data: {
148+
title: "E2E tenant-floor org",
149+
slug: `e2e-tenant-${randomBytes(6).toString("hex")}`,
150+
members: { create: { userId: owner.id, role: "ADMIN" } },
151+
},
152+
});
153+
return { server, owner, org };
154+
}
155+
156+
it("denies a non-member: no roles catalogue leaked", async () => {
157+
const { server, org } = await seedOrgWithOwner();
158+
const outsider = await seedConfirmedUser(server.prisma);
159+
const cookie = await seedTestSession({ userId: outsider.id });
160+
const res = await server.webapp.fetch(rolesData(org.slug), {
161+
redirect: "manual",
162+
headers: { Cookie: cookie },
163+
});
164+
const body = await res.text();
165+
// With the tenant floor a non-member is denied (a redirect), so they never
166+
// get the loader's 200 payload. Before the fix the permissive ability let
167+
// the loader return the org's role/permission catalogue.
168+
expect(res.status).not.toBe(200);
169+
expect(body).not.toContain("manage:members");
170+
});
171+
172+
it("allows a member: the loader returns the catalogue", async () => {
173+
const { server, owner, org } = await seedOrgWithOwner();
174+
const cookie = await seedTestSession({ userId: owner.id });
175+
const res = await server.webapp.fetch(rolesData(org.slug), {
176+
redirect: "manual",
177+
headers: { Cookie: cookie },
178+
});
179+
expect(res.status).toBe(200);
180+
});
181+
});
118182
});

0 commit comments

Comments
 (0)