Skip to content

Commit 0f2f1ea

Browse files
d-csTrigger.dev RepoOps
authored andcommitted
refactor(webapp): extract shared dashboard authorization foundation
Extract shared dashboard authorization helpers for resolving tenant and environment scope before feature-specific permission checks. Existing route behavior remains unchanged until callers adopt them. Mono-RevId: 981f4694a2ab54f1c39867449704c317efc91b29
1 parent 6d46534 commit 0f2f1ea

4 files changed

Lines changed: 292 additions & 0 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
2+
import type { HostRbacController } from "@trigger.dev/rbac";
3+
import { throwPermissionDenied } from "~/utils/permissionDenied";
4+
5+
/** Resolve tenancy on the writer before asking the optional plugin for an ability. */
6+
export async function dashboardEnvironmentAccess(
7+
database: PrismaClientOrTransaction,
8+
controller: Pick<HostRbacController, "authenticateSession">,
9+
request: Request,
10+
userId: string,
11+
environmentId: string
12+
) {
13+
const environment = await database.runtimeEnvironment.findFirst({
14+
where: {
15+
id: environmentId,
16+
archivedAt: null,
17+
project: { deletedAt: null },
18+
organization: { deletedAt: null, members: { some: { userId } } },
19+
},
20+
select: { id: true, type: true, projectId: true, organizationId: true },
21+
});
22+
if (!environment) throw new Response("Environment not found", { status: 404 });
23+
24+
const auth = await controller.authenticateSession(request, {
25+
userId,
26+
organizationId: environment.organizationId,
27+
projectId: environment.projectId,
28+
});
29+
if (!auth.ok) throwPermissionDenied();
30+
return { environment, ability: auth.ability };
31+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import rbac from "@trigger.dev/rbac";
3+
import { expect } from "vitest";
4+
import { dashboardEnvironmentAccess } from "./dashboardEnvironmentAccess.server";
5+
6+
postgresTest(
7+
"the writer resolves the actual tenant and tier; self-hosted members retain permissive abilities",
8+
async ({ prisma }) => {
9+
const member = await prisma.user.create({
10+
data: {
11+
email: "runtime-member@example.test",
12+
authenticationMethod: "MAGIC_LINK",
13+
admin: false,
14+
},
15+
});
16+
const outsider = await prisma.user.create({
17+
data: { email: "runtime-outsider@example.test", authenticationMethod: "MAGIC_LINK" },
18+
});
19+
const organization = await prisma.organization.create({
20+
data: {
21+
slug: "runtime-auth",
22+
title: "Runtime auth",
23+
members: { create: { userId: member.id, role: "MEMBER" } },
24+
},
25+
});
26+
const project = await prisma.project.create({
27+
data: {
28+
slug: "runtime-auth",
29+
name: "Runtime auth",
30+
externalRef: "runtime-auth",
31+
organizationId: organization.id,
32+
},
33+
});
34+
const controller = rbac.create(prisma, { forceFallback: true });
35+
const request = new Request("https://example.test/orgs/wrong/projects/wrong/env/dev");
36+
37+
for (const type of ["DEVELOPMENT", "STAGING", "PREVIEW", "PRODUCTION"] as const) {
38+
const environment = await prisma.runtimeEnvironment.create({
39+
data: {
40+
slug: type,
41+
type,
42+
shortcode: type,
43+
apiKey: `key_${type}`,
44+
pkApiKey: `pk_${type}`,
45+
projectId: project.id,
46+
organizationId: organization.id,
47+
},
48+
});
49+
const access = await dashboardEnvironmentAccess(
50+
prisma,
51+
controller,
52+
request,
53+
member.id,
54+
environment.id
55+
);
56+
expect(access.environment).toEqual({
57+
id: environment.id,
58+
type,
59+
organizationId: organization.id,
60+
projectId: project.id,
61+
});
62+
expect(access.ability.can("write", { type: "tasks", envType: type })).toBe(true);
63+
await expect(
64+
dashboardEnvironmentAccess(prisma, controller, request, outsider.id, environment.id)
65+
).rejects.toMatchObject({ status: 404 });
66+
await expect(
67+
dashboardEnvironmentAccess(prisma, controller, request, "", environment.id)
68+
).rejects.toMatchObject({ status: 404 });
69+
// Each inactive ancestor must independently reject an otherwise valid member.
70+
for (const inactiveTarget of ["environment", "project", "organization"] as const) {
71+
if (inactiveTarget === "environment") {
72+
await prisma.runtimeEnvironment.update({
73+
where: { id: environment.id },
74+
data: { archivedAt: new Date() },
75+
});
76+
} else if (inactiveTarget === "project") {
77+
await prisma.project.update({
78+
where: { id: project.id },
79+
data: { deletedAt: new Date() },
80+
});
81+
} else {
82+
await prisma.organization.update({
83+
where: { id: organization.id },
84+
data: { deletedAt: new Date() },
85+
});
86+
}
87+
await expect(
88+
dashboardEnvironmentAccess(prisma, controller, request, member.id, environment.id)
89+
).rejects.toMatchObject({ status: 404 });
90+
await prisma.runtimeEnvironment.update({
91+
where: { id: environment.id },
92+
data: { archivedAt: null },
93+
});
94+
await prisma.project.update({ where: { id: project.id }, data: { deletedAt: null } });
95+
await prisma.organization.update({
96+
where: { id: organization.id },
97+
data: { deletedAt: null },
98+
});
99+
}
100+
}
101+
await expect(
102+
dashboardEnvironmentAccess(prisma, controller, request, member.id, "missing")
103+
).rejects.toMatchObject({ status: 404 });
104+
}
105+
);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { type PrismaClientOrTransaction, RuntimeEnvironmentType } from "@trigger.dev/database";
2+
import type { HostRbacController } from "@trigger.dev/rbac";
3+
import { throwPermissionDenied } from "~/utils/permissionDenied";
4+
5+
export async function dashboardResourceAccess(
6+
database: PrismaClientOrTransaction,
7+
controller: Pick<HostRbacController, "authenticateSession">,
8+
request: Request,
9+
userId: string,
10+
scope: { organizationId: string; projectId?: string; environmentId?: string }
11+
) {
12+
const organization = await database.organization.findFirst({
13+
where: { id: scope.organizationId, deletedAt: null, members: { some: { userId } } },
14+
select: { id: true },
15+
});
16+
if (!organization) throw new Response("Organization not found", { status: 404 });
17+
// Both lookups depend only on the validated organization and supplied scope.
18+
const [project, environment] = await Promise.all([
19+
scope.projectId
20+
? database.project.findFirst({
21+
where: { id: scope.projectId, organizationId: organization.id, deletedAt: null },
22+
select: { id: true },
23+
})
24+
: undefined,
25+
scope.environmentId
26+
? database.runtimeEnvironment.findFirst({
27+
where: {
28+
id: scope.environmentId,
29+
organizationId: organization.id,
30+
projectId: scope.projectId,
31+
archivedAt: null,
32+
project: { deletedAt: null },
33+
},
34+
select: { id: true, type: true, projectId: true },
35+
})
36+
: undefined,
37+
]);
38+
if (scope.projectId && !project) throw new Response("Project not found", { status: 404 });
39+
if (scope.environmentId && !environment)
40+
throw new Response("Environment not found", { status: 404 });
41+
const auth = await controller.authenticateSession(request, {
42+
userId,
43+
organizationId: organization.id,
44+
projectId: environment?.projectId ?? scope.projectId,
45+
});
46+
if (!auth.ok) throwPermissionDenied();
47+
const can = (action: string, type: string) =>
48+
auth.ability.can(action, { type, ...(environment ? { envType: environment.type } : {}) });
49+
const canAcrossEnvironments = (
50+
action: string,
51+
type: string,
52+
environmentTypes: readonly RuntimeEnvironmentType[]
53+
) =>
54+
(environmentTypes.length ? environmentTypes : Object.values(RuntimeEnvironmentType)).every(
55+
(envType) => auth.ability.can(action, { type, envType })
56+
);
57+
return {
58+
can,
59+
canAcrossEnvironments,
60+
requireAcrossEnvironments(
61+
action: string,
62+
type: string,
63+
environmentTypes: readonly RuntimeEnvironmentType[]
64+
) {
65+
if (!canAcrossEnvironments(action, type, environmentTypes))
66+
throwPermissionDenied("You don't have permission in every affected environment.");
67+
},
68+
require(action: string, type: string) {
69+
if (!can(action, type))
70+
throwPermissionDenied("You don't have permission to perform this action.");
71+
},
72+
};
73+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import rbac from "@trigger.dev/rbac";
3+
import { expect } from "vitest";
4+
import { dashboardResourceAccess } from "./dashboardResourceAccess.server";
5+
6+
postgresTest(
7+
"resource access resolves writer tenancy before permissive fallback",
8+
async ({ prisma }) => {
9+
const user = await prisma.user.create({
10+
data: { email: "resource@example.test", authenticationMethod: "MAGIC_LINK" },
11+
});
12+
const org = await prisma.organization.create({
13+
data: {
14+
slug: "resource",
15+
title: "Resource",
16+
members: { create: { userId: user.id, role: "MEMBER" } },
17+
},
18+
});
19+
const project = await prisma.project.create({
20+
data: { slug: "resource", name: "Resource", externalRef: "resource", organizationId: org.id },
21+
});
22+
const environment = await prisma.runtimeEnvironment.create({
23+
data: {
24+
slug: "prod",
25+
shortcode: "prod",
26+
type: "PRODUCTION",
27+
apiKey: "key",
28+
pkApiKey: "pk",
29+
projectId: project.id,
30+
organizationId: org.id,
31+
},
32+
});
33+
const controller = rbac.create(prisma, { forceFallback: true });
34+
const request = new Request("https://example.test/env/dev");
35+
const scope = { organizationId: org.id, projectId: project.id, environmentId: environment.id };
36+
const access = await dashboardResourceAccess(prisma, controller, request, user.id, scope);
37+
for (const subject of [
38+
"webhooks",
39+
"alerts",
40+
"dashboards",
41+
"errors",
42+
"query",
43+
"sessions",
44+
"slack",
45+
"privateConnections",
46+
"dashboardAgent",
47+
]) {
48+
expect(access.can("write", subject)).toBe(true);
49+
expect(() => access.require("write", subject)).not.toThrow();
50+
}
51+
for (const invalid of [
52+
{ ...scope, organizationId: "foreign" },
53+
{ ...scope, projectId: "foreign" },
54+
{ ...scope, environmentId: "foreign" },
55+
]) {
56+
await expect(
57+
dashboardResourceAccess(prisma, controller, request, user.id, invalid)
58+
).rejects.toMatchObject({ status: 404 });
59+
}
60+
await expect(
61+
dashboardResourceAccess(prisma, controller, request, "outsider", scope)
62+
).rejects.toMatchObject({ status: 404 });
63+
// Environment-only scopes must enforce the owning project's lifecycle too.
64+
const environmentScope = { organizationId: org.id, environmentId: environment.id };
65+
await expect(
66+
dashboardResourceAccess(prisma, controller, request, user.id, environmentScope)
67+
).resolves.toBeDefined();
68+
await prisma.project.update({ where: { id: project.id }, data: { deletedAt: new Date() } });
69+
for (const deletedProjectScope of [scope, environmentScope]) {
70+
await expect(
71+
dashboardResourceAccess(prisma, controller, request, user.id, deletedProjectScope)
72+
).rejects.toMatchObject({ status: 404 });
73+
}
74+
await prisma.project.update({ where: { id: project.id }, data: { deletedAt: null } });
75+
await prisma.runtimeEnvironment.update({
76+
where: { id: environment.id },
77+
data: { archivedAt: new Date() },
78+
});
79+
await expect(
80+
dashboardResourceAccess(prisma, controller, request, user.id, scope)
81+
).rejects.toMatchObject({ status: 404 });
82+
}
83+
);

0 commit comments

Comments
 (0)