@@ -5,7 +5,9 @@ import { runStore } from "~/v3/runStore.server";
55import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server" ;
66import { logger } from "~/services/logger.server" ;
77import { getUsername } from "~/utils/username" ;
8+ import { hashApiKey } from "~/utils/apiKeys" ;
89import { isDefaultDevBranch , sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch" ;
10+ import { scopesGrantFullAccess } from "@trigger.dev/rbac" ;
911
1012export type { RuntimeEnvironment } ;
1113
@@ -93,11 +95,21 @@ export function toAuthenticated(
9395 } ;
9496}
9597
96- export async function findEnvironmentByApiKey (
98+ export type ApiKeyEnvironmentResolution =
99+ | { ok : true ; environment : AuthenticatedEnvironment }
100+ | { ok : false ; reason : "not-found" | "restricted" } ;
101+
102+ /**
103+ * Resolve an environment from a raw API key for legacy routes that do not
104+ * declare authorization. Additional keys are accepted only when their stored
105+ * scopes explicitly grant full access; restricted keys fail closed here
106+ * (`reason: "restricted"`, so callers can explain the rejection).
107+ */
108+ async function resolveEnvironmentByApiKey (
97109 apiKey : string ,
98110 branchName : string | undefined ,
99- tx : PrismaClientOrTransaction = $replica
100- ) : Promise < AuthenticatedEnvironment | null > {
111+ tx : PrismaClientOrTransaction
112+ ) : Promise < ApiKeyEnvironmentResolution > {
101113 const branch = sanitizeBranchName ( branchName ) ?? undefined ;
102114
103115 const include = {
@@ -112,19 +124,21 @@ export async function findEnvironmentByApiKey(
112124 : undefined ,
113125 } satisfies Prisma . RuntimeEnvironmentInclude ;
114126
127+ const now = new Date ( ) ;
128+ let additionalApiKey : { id : string ; lastUsedAt : Date | null } | null = null ;
115129 let environment = await tx . runtimeEnvironment . findFirst ( {
116130 where : {
117131 apiKey,
118132 } ,
119133 include,
120134 } ) ;
121135
122- // Fall back to keys that were revoked within the grace window
136+ // Fall back to root keys that were rotated within the grace window.
123137 if ( ! environment ) {
124138 const revokedApiKey = await tx . revokedApiKey . findFirst ( {
125139 where : {
126140 apiKey,
127- expiresAt : { gt : new Date ( ) } ,
141+ expiresAt : { gt : now } ,
128142 } ,
129143 include : {
130144 runtimeEnvironment : { include } ,
@@ -134,58 +148,141 @@ export async function findEnvironmentByApiKey(
134148 environment = revokedApiKey ?. runtimeEnvironment ?? null ;
135149 }
136150
151+ // Additional keys are host-owned credentials. Legacy routes cannot apply a
152+ // scoped ability, so only an explicit full-access scope is accepted.
137153 if ( ! environment ) {
138- return null ;
154+ const match = await tx . apiKey . findFirst ( {
155+ where : {
156+ keyHash : hashApiKey ( apiKey ) ,
157+ revokedAt : null ,
158+ OR : [ { expiresAt : null } , { expiresAt : { gt : now } } ] ,
159+ } ,
160+ select : {
161+ id : true ,
162+ lastUsedAt : true ,
163+ scopes : true ,
164+ runtimeEnvironment : { include } ,
165+ } ,
166+ } ) ;
167+
168+ if ( match && ! scopesGrantFullAccess ( match . scopes ) ) {
169+ return { ok : false , reason : "restricted" } ;
170+ }
171+
172+ additionalApiKey = match ? { id : match . id , lastUsedAt : match . lastUsedAt } : null ;
173+ environment = match ?. runtimeEnvironment ?? null ;
174+ }
175+
176+ if ( ! environment ) {
177+ return { ok : false , reason : "not-found" } ;
178+ }
179+
180+ if (
181+ additionalApiKey &&
182+ ( ! additionalApiKey . lastUsedAt ||
183+ additionalApiKey . lastUsedAt < new Date ( now . getTime ( ) - 300_000 ) )
184+ ) {
185+ try {
186+ // Deliberately the primary `prisma`, not `tx`: `tx` defaults to the
187+ // read replica (and may be a caller's transaction), and this last-used
188+ // telemetry write must hit the writer. It's throttled to once every 5
189+ // minutes per key and best-effort — auth never fails if it can't record.
190+ await prisma . apiKey . updateMany ( {
191+ where : {
192+ id : additionalApiKey . id ,
193+ revokedAt : null ,
194+ OR : [ { expiresAt : null } , { expiresAt : { gt : now } } ] ,
195+ } ,
196+ data : { lastUsedAt : now } ,
197+ } ) ;
198+ } catch ( error ) {
199+ logger . warn ( "Failed to update API key last-used timestamp" , {
200+ apiKeyId : additionalApiKey . id ,
201+ error,
202+ } ) ;
203+ }
139204 }
140205
141206 //don't return deleted projects
142207 if ( environment . project . deletedAt !== null ) {
143- return null ;
208+ return { ok : false , reason : "not-found" } ;
144209 }
145210
146211 if ( environment . type === "PREVIEW" ) {
147212 if ( ! branch ) {
148213 logger . warn ( "findEnvironmentByApiKey(): Preview env with no branch name provided" , {
149214 environmentId : environment . id ,
150215 } ) ;
151- return null ;
216+ return { ok : false , reason : "not-found" } ;
152217 }
153218
154219 const childEnvironment = environment . childEnvironments . at ( 0 ) ;
155220
156221 if ( childEnvironment ) {
157- return toAuthenticated ( {
158- ...childEnvironment ,
159- apiKey : environment . apiKey ,
160- orgMember : environment . orgMember ,
161- organization : environment . organization ,
162- project : environment . project ,
163- } ) ;
222+ return {
223+ ok : true ,
224+ environment : toAuthenticated ( {
225+ ...childEnvironment ,
226+ apiKey : environment . apiKey ,
227+ orgMember : environment . orgMember ,
228+ organization : environment . organization ,
229+ project : environment . project ,
230+ } ) ,
231+ } ;
164232 }
165233
166234 //A branch was specified but no child environment was found
167- return null ;
235+ return { ok : false , reason : "not-found" } ;
168236 }
169237
170238 // If there is a named DEV branch (other than default), return it
171239 if ( environment . type === "DEVELOPMENT" && branch !== undefined && ! isDefaultDevBranch ( branch ) ) {
172240 const childEnvironment = environment . childEnvironments . at ( 0 ) ;
173241
174242 if ( childEnvironment ) {
175- return toAuthenticated ( {
176- ...childEnvironment ,
177- apiKey : environment . apiKey ,
178- orgMember : environment . orgMember ,
179- organization : environment . organization ,
180- project : environment . project ,
181- } ) ;
243+ return {
244+ ok : true ,
245+ environment : toAuthenticated ( {
246+ ...childEnvironment ,
247+ apiKey : environment . apiKey ,
248+ orgMember : environment . orgMember ,
249+ organization : environment . organization ,
250+ project : environment . project ,
251+ } ) ,
252+ } ;
182253 }
183254
184255 //A branch was specified but no child environment was found
185- return null ;
256+ return { ok : false , reason : "not-found" } ;
186257 }
187258
188- return toAuthenticated ( environment ) ;
259+ return { ok : true , environment : toAuthenticated ( environment ) } ;
260+ }
261+
262+ /**
263+ * Resolve an environment from a raw API key. Root and grace-window keys keep
264+ * their legacy behavior; additional keys with restricted scopes fail closed.
265+ */
266+ export async function findEnvironmentByApiKey (
267+ apiKey : string ,
268+ branchName : string | undefined ,
269+ tx : PrismaClientOrTransaction = $replica
270+ ) : Promise < AuthenticatedEnvironment | null > {
271+ const resolution = await resolveEnvironmentByApiKey ( apiKey , branchName , tx ) ;
272+ return resolution . ok ? resolution . environment : null ;
273+ }
274+
275+ /**
276+ * Like `findEnvironmentByApiKey`, but distinguishes a restricted additional
277+ * key (fails closed on legacy routes) from an unknown key so callers can
278+ * return an accurate error message.
279+ */
280+ export async function findEnvironmentByApiKeyWithResolution (
281+ apiKey : string ,
282+ branchName : string | undefined ,
283+ tx : PrismaClientOrTransaction = $replica
284+ ) : Promise < ApiKeyEnvironmentResolution > {
285+ return resolveEnvironmentByApiKey ( apiKey , branchName , tx ) ;
189286}
190287
191288/**
0 commit comments