|
1 | 1 | import { type RuntimeEnvironment } from "@trigger.dev/database"; |
2 | | -import { type PrismaClient, prisma } from "~/db.server"; |
| 2 | +import { scopesGrantFullAccess, type RoleBaseAccessController } from "@trigger.dev/rbac"; |
| 3 | +import { type PrismaReplicaClient, $replica } from "~/db.server"; |
3 | 4 | import { type Project } from "~/models/project.server"; |
4 | 5 | import { type User } from "~/models/user.server"; |
| 6 | +import { rbac } from "~/services/rbac.server"; |
| 7 | +import { obfuscateApiKey } from "~/utils/apiKeys"; |
| 8 | + |
| 9 | +type ApiKeyPolicyPresenter = Pick< |
| 10 | + RoleBaseAccessController, |
| 11 | + "apiKeyPresets" | "describeApiKeyPolicy" |
| 12 | +>; |
5 | 13 |
|
6 | 14 | export class ApiKeysPresenter { |
7 | | - #prismaClient: PrismaClient; |
| 15 | + // Read-only presenter for a dashboard page — all queries below are reads, so |
| 16 | + // default to the replica and keep this off the writer. |
| 17 | + #prismaClient: PrismaReplicaClient; |
| 18 | + #rbac: ApiKeyPolicyPresenter; |
8 | 19 |
|
9 | | - constructor(prismaClient: PrismaClient = prisma) { |
| 20 | + constructor( |
| 21 | + prismaClient: PrismaReplicaClient = $replica, |
| 22 | + rbacController: ApiKeyPolicyPresenter = rbac |
| 23 | + ) { |
10 | 24 | this.#prismaClient = prismaClient; |
| 25 | + this.#rbac = rbacController; |
11 | 26 | } |
12 | 27 |
|
13 | 28 | public async call({ |
14 | 29 | userId, |
| 30 | + organizationSlug, |
15 | 31 | projectSlug, |
16 | 32 | environmentSlug, |
| 33 | + showRevoked = false, |
17 | 34 | }: { |
18 | 35 | userId: User["id"]; |
| 36 | + organizationSlug: string; |
19 | 37 | projectSlug: Project["slug"]; |
20 | 38 | environmentSlug: RuntimeEnvironment["slug"]; |
| 39 | + showRevoked?: boolean; |
21 | 40 | }) { |
22 | 41 | const environment = await this.#prismaClient.runtimeEnvironment.findFirst({ |
23 | 42 | select: { |
24 | 43 | id: true, |
25 | | - apiKey: true, |
26 | 44 | type: true, |
27 | 45 | slug: true, |
28 | | - updatedAt: true, |
29 | | - orgMember: { |
30 | | - select: { |
31 | | - userId: true, |
32 | | - }, |
33 | | - }, |
34 | 46 | branchName: true, |
35 | | - parentEnvironment: { |
36 | | - select: { |
37 | | - id: true, |
38 | | - apiKey: true, |
39 | | - }, |
40 | | - }, |
41 | | - project: { |
42 | | - select: { |
43 | | - id: true, |
44 | | - }, |
| 47 | + parentEnvironmentId: true, |
| 48 | + taskIdentifiers: { |
| 49 | + where: { isInLatestDeployment: true }, |
| 50 | + orderBy: { slug: "asc" }, |
| 51 | + select: { slug: true }, |
45 | 52 | }, |
| 53 | + project: { select: { id: true } }, |
| 54 | + organizationId: true, |
46 | 55 | }, |
47 | 56 | where: { |
48 | | - project: { |
49 | | - slug: projectSlug, |
50 | | - }, |
51 | | - organization: { |
52 | | - members: { |
53 | | - some: { |
54 | | - userId, |
55 | | - }, |
56 | | - }, |
57 | | - }, |
| 57 | + project: { slug: projectSlug, organization: { slug: organizationSlug } }, |
| 58 | + organization: { slug: organizationSlug, members: { some: { userId } } }, |
58 | 59 | slug: environmentSlug, |
59 | | - orgMember: |
60 | | - environmentSlug === "dev" |
61 | | - ? { |
62 | | - userId, |
63 | | - } |
64 | | - : undefined, |
| 60 | + OR: [{ type: { not: "DEVELOPMENT" } }, { type: "DEVELOPMENT", orgMember: { userId } }], |
65 | 61 | }, |
66 | 62 | }); |
67 | 63 |
|
68 | 64 | if (!environment) { |
69 | 65 | throw new Error("Environment not found"); |
70 | 66 | } |
71 | 67 |
|
72 | | - const vercelIntegration = await this.#prismaClient.organizationProjectIntegration.findFirst({ |
73 | | - where: { |
74 | | - projectId: environment.project.id, |
75 | | - deletedAt: null, |
76 | | - organizationIntegration: { service: "VERCEL", deletedAt: null }, |
77 | | - }, |
78 | | - select: { id: true }, |
79 | | - }); |
| 68 | + const keyEnvironmentId = environment.parentEnvironmentId ?? environment.id; |
| 69 | + |
| 70 | + const [keyEnvironment, vercelIntegration] = await Promise.all([ |
| 71 | + this.#prismaClient.runtimeEnvironment.findUniqueOrThrow({ |
| 72 | + where: { id: keyEnvironmentId }, |
| 73 | + select: { |
| 74 | + id: true, |
| 75 | + apiKey: true, |
| 76 | + type: true, |
| 77 | + createdAt: true, |
| 78 | + apiKeys: { |
| 79 | + where: showRevoked ? undefined : { revokedAt: null }, |
| 80 | + orderBy: { createdAt: "desc" }, |
| 81 | + select: { |
| 82 | + id: true, |
| 83 | + name: true, |
| 84 | + lastFour: true, |
| 85 | + presetId: true, |
| 86 | + scopes: true, |
| 87 | + lastUsedAt: true, |
| 88 | + revokedAt: true, |
| 89 | + expiresAt: true, |
| 90 | + createdAt: true, |
| 91 | + createdBy: { |
| 92 | + select: { |
| 93 | + id: true, |
| 94 | + email: true, |
| 95 | + name: true, |
| 96 | + displayName: true, |
| 97 | + }, |
| 98 | + }, |
| 99 | + }, |
| 100 | + }, |
| 101 | + }, |
| 102 | + }), |
| 103 | + this.#prismaClient.organizationProjectIntegration.findFirst({ |
| 104 | + where: { |
| 105 | + projectId: environment.project.id, |
| 106 | + deletedAt: null, |
| 107 | + organizationIntegration: { service: "VERCEL", deletedAt: null }, |
| 108 | + }, |
| 109 | + select: { id: true }, |
| 110 | + }), |
| 111 | + ]); |
| 112 | + |
| 113 | + const [presets, policyDescriptions] = await Promise.all([ |
| 114 | + this.#rbac.apiKeyPresets(environment.organizationId), |
| 115 | + Promise.all( |
| 116 | + keyEnvironment.apiKeys.map((apiKey) => |
| 117 | + this.#rbac.describeApiKeyPolicy({ |
| 118 | + presetId: apiKey.presetId, |
| 119 | + scopes: apiKey.scopes, |
| 120 | + }) |
| 121 | + ) |
| 122 | + ), |
| 123 | + ]); |
| 124 | + const presetsById = new Map(presets?.map((preset) => [preset.id, preset])); |
| 125 | + const { taskIdentifiers, organizationId: _organizationId, ...environmentData } = environment; |
80 | 126 |
|
81 | 127 | return { |
82 | 128 | environment: { |
83 | | - ...environment, |
84 | | - apiKey: environment?.parentEnvironment?.apiKey ?? environment?.apiKey, |
| 129 | + ...environmentData, |
| 130 | + apiKey: keyEnvironment.apiKey, |
| 131 | + keyEnvironmentId, |
| 132 | + }, |
| 133 | + availableTasks: taskIdentifiers.map((task) => task.slug), |
| 134 | + rootApiKey: { |
| 135 | + id: keyEnvironment.id, |
| 136 | + name: "Root API key", |
| 137 | + value: keyEnvironment.apiKey, |
| 138 | + obfuscated: obfuscateApiKey(keyEnvironment.type, keyEnvironment.apiKey.slice(-4)), |
| 139 | + createdAt: keyEnvironment.createdAt, |
85 | 140 | }, |
| 141 | + apiKeys: keyEnvironment.apiKeys.map((apiKey, index) => { |
| 142 | + const { presetId, scopes, ...apiKeyData } = apiKey; |
| 143 | + const description = policyDescriptions[index]; |
| 144 | + const preset = presetId ? presetsById.get(presetId) : undefined; |
| 145 | + const isFullAccess = scopesGrantFullAccess(scopes); |
| 146 | + |
| 147 | + return { |
| 148 | + ...apiKeyData, |
| 149 | + access: { |
| 150 | + presetId, |
| 151 | + label: preset?.label ?? (presetId === null && isFullAccess ? "Full access" : "Custom"), |
| 152 | + taskIdentifiers: description.taskIdentifiers, |
| 153 | + usesTaskSelection: |
| 154 | + preset?.usesTaskSelection ?? description.taskIdentifiers !== undefined, |
| 155 | + }, |
| 156 | + obfuscated: obfuscateApiKey(keyEnvironment.type, apiKey.lastFour, "additional"), |
| 157 | + }; |
| 158 | + }), |
| 159 | + presets, |
86 | 160 | hasVercelIntegration: vercelIntegration !== null, |
87 | 161 | }; |
88 | 162 | } |
|
0 commit comments