@@ -56,12 +56,14 @@ vi.mock("@internal/run-engine", () => ({
5656} ) ) ;
5757
5858const { loader } = await import ( "~/routes/api.v1.projects.$projectRef.environments" ) ;
59+ const { assertUserActorScope, resolveUserActorEnvironmentScope } =
60+ await import ( "~/services/userActorEnvironment.server" ) ;
5961
6062function suffix ( ) {
6163 return Math . random ( ) . toString ( 36 ) . slice ( 2 , 10 ) ;
6264}
6365
64- /** An org with one project, prod/staging/dev environments, a member and an outsider. */
66+ /** An org with two projects, each with prod/staging/dev environments, a member and an outsider. */
6567async function seedOrg ( prisma : PrismaClient ) {
6668 const slug = `orgenvs_${ suffix ( ) } ` ;
6769 const member = await prisma . user . create ( {
@@ -74,31 +76,45 @@ async function seedOrg(prisma: PrismaClient) {
7476 const orgMember = await prisma . orgMember . create ( {
7577 data : { organizationId : organization . id , userId : member . id , role : "ADMIN" } ,
7678 } ) ;
77- const project = await prisma . project . create ( {
78- data : { name : slug , slug, organizationId : organization . id , externalRef : `proj_${ slug } ` } ,
79- } ) ;
80- const environmentFor = ( envSlug : string , type : "PRODUCTION" | "STAGING" | "DEVELOPMENT" ) =>
81- prisma . runtimeEnvironment . create ( {
79+
80+ async function projectWithEnvironments ( name : string ) {
81+ const projectSlug = `${ slug } _${ name } ` ;
82+ const project = await prisma . project . create ( {
8283 data : {
83- slug : envSlug ,
84- type,
85- projectId : project . id ,
84+ name : projectSlug ,
85+ slug : projectSlug ,
8686 organizationId : organization . id ,
87- apiKey : `tr_${ envSlug } _${ slug } ` ,
88- pkApiKey : `pk_${ envSlug } _${ slug } ` ,
89- shortcode : `${ envSlug } ${ suffix ( ) } ` ,
90- ...( type === "DEVELOPMENT" ? { orgMemberId : orgMember . id } : { } ) ,
87+ externalRef : `proj_${ projectSlug } ` ,
9188 } ,
9289 } ) ;
90+ const environmentFor = ( envSlug : string , type : "PRODUCTION" | "STAGING" | "DEVELOPMENT" ) =>
91+ prisma . runtimeEnvironment . create ( {
92+ data : {
93+ slug : envSlug ,
94+ type,
95+ projectId : project . id ,
96+ organizationId : organization . id ,
97+ apiKey : `tr_${ envSlug } _${ projectSlug } ` ,
98+ pkApiKey : `pk_${ envSlug } _${ projectSlug } ` ,
99+ shortcode : `${ envSlug } ${ suffix ( ) } ` ,
100+ ...( type === "DEVELOPMENT" ? { orgMemberId : orgMember . id } : { } ) ,
101+ } ,
102+ } ) ;
103+
104+ return {
105+ project,
106+ prod : await environmentFor ( "prod" , "PRODUCTION" ) ,
107+ staging : await environmentFor ( "stg" , "STAGING" ) ,
108+ dev : await environmentFor ( "dev" , "DEVELOPMENT" ) ,
109+ } ;
110+ }
93111
94112 return {
95113 member,
96114 outsider,
97115 organization,
98- project,
99- prod : await environmentFor ( "prod" , "PRODUCTION" ) ,
100- staging : await environmentFor ( "stg" , "STAGING" ) ,
101- dev : await environmentFor ( "dev" , "DEVELOPMENT" ) ,
116+ current : await projectWithEnvironments ( "current" ) ,
117+ sibling : await projectWithEnvironments ( "sibling" ) ,
102118 } ;
103119}
104120
@@ -138,54 +154,83 @@ async function callLoader(opts: {
138154 return { status : response . status , body : await response . json ( ) } ;
139155}
140156
157+ async function statusOf ( promise : Promise < unknown > ) {
158+ try {
159+ await promise ;
160+ return 200 ;
161+ } catch ( thrown ) {
162+ if ( thrown instanceof Response ) return thrown . status ;
163+ throw thrown ;
164+ }
165+ }
166+
141167postgresTest (
142168 "org-wide user-actor token lists a sibling project's environments" ,
143169 async ( { prisma } ) => {
144170 ctx . prisma = prisma ;
145171 const orgA = await seedOrg ( prisma ) ;
146172 const orgB = await seedOrg ( prisma ) ;
147173
148- // Its own org's project: every environment, dev included — the whole point of the sweep.
149- const own = await callLoader ( {
150- projectRef : orgA . project . externalRef ,
174+ // The shape the dashboard agent actually mints: the turn's environment plus its organization.
175+ const minted = {
151176 userId : orgA . member . id ,
152177 organizationId : orgA . organization . id ,
153- } ) ;
178+ environmentId : orgA . current . dev . id ,
179+ } ;
180+
181+ // A sibling project of the same org — the sweep this whole route exists for. Every
182+ // environment, dev included, none of them the one the token was minted for.
183+ const sibling = await callLoader ( { ...minted , projectRef : orgA . sibling . project . externalRef } ) ;
184+ expect ( sibling . status ) . toBe ( 200 ) ;
185+ expect ( sibling . body . map ( ( env : any ) => env . slug ) . sort ( ) ) . toEqual ( [ "dev" , "prod" , "stg" ] ) ;
186+
187+ // Its own project answers the same way: with an org claim, the org is the boundary.
188+ const own = await callLoader ( { ...minted , projectRef : orgA . current . project . externalRef } ) ;
154189 expect ( own . status ) . toBe ( 200 ) ;
155190 expect ( own . body . map ( ( env : any ) => env . slug ) . sort ( ) ) . toEqual ( [ "dev" , "prod" , "stg" ] ) ;
156191
157- // A project outside the claimed organization is refused on the claim alone.
158- const foreign = await callLoader ( {
159- projectRef : orgB . project . externalRef ,
160- userId : orgA . member . id ,
161- organizationId : orgA . organization . id ,
162- } ) ;
192+ // A project outside the claimed organization.
193+ const foreign = await callLoader ( { ...minted , projectRef : orgB . current . project . externalRef } ) ;
163194 expect ( foreign . status ) . toBe ( 403 ) ;
164195 expect ( foreign . body . code ) . toBe ( "forbidden_environment" ) ;
165196
166197 // A claim naming the right org still needs membership of it.
167198 const outsider = await callLoader ( {
168- projectRef : orgA . project . externalRef ,
199+ projectRef : orgA . current . project . externalRef ,
169200 userId : orgB . outsider . id ,
170201 organizationId : orgA . organization . id ,
171202 } ) ;
172203 expect ( outsider . status ) . toBe ( 404 ) ;
173204
174- // The environment-claim path is unchanged: exactly its own environment, and nothing elsewhere.
175- const scoped = await callLoader ( {
176- projectRef : orgA . project . externalRef ,
177- userId : orgA . member . id ,
178- environmentId : orgA . staging . id ,
179- } ) ;
205+ // An environment claim with no org claim is unchanged: that environment only, and nothing
206+ // in another project.
207+ const envOnly = { userId : orgA . member . id , environmentId : orgA . current . staging . id } ;
208+ const scoped = await callLoader ( { ...envOnly , projectRef : orgA . current . project . externalRef } ) ;
180209 expect ( scoped . status ) . toBe ( 200 ) ;
181210 expect ( scoped . body . map ( ( env : any ) => env . slug ) ) . toEqual ( [ "stg" ] ) ;
182211
183- const scopedForeign = await callLoader ( {
184- projectRef : orgB . project . externalRef ,
185- userId : orgA . member . id ,
186- environmentId : orgA . staging . id ,
212+ const scopedSibling = await callLoader ( {
213+ ...envOnly ,
214+ projectRef : orgA . sibling . project . externalRef ,
187215 } ) ;
188- expect ( scopedForeign . status ) . toBe ( 403 ) ;
189- expect ( scopedForeign . body . code ) . toBe ( "forbidden_environment" ) ;
216+ expect ( scopedSibling . status ) . toBe ( 403 ) ;
217+ expect ( scopedSibling . body . code ) . toBe ( "forbidden_environment" ) ;
218+
219+ // The control: a route that hasn't opted in refuses the same org-wide token, so nothing is
220+ // loosened for the PAT routes at large.
221+ const claims = { ...minted , client : "dashboard-agent" } ;
222+ expect (
223+ await statusOf (
224+ resolveUserActorEnvironmentScope ( claims , { projectId : orgA . sibling . project . id } )
225+ )
226+ ) . toBe ( 403 ) ;
227+ expect (
228+ await statusOf (
229+ assertUserActorScope ( claims , {
230+ organizationId : orgA . organization . id ,
231+ projectId : orgA . sibling . project . id ,
232+ } )
233+ )
234+ ) . toBe ( 403 ) ;
190235 }
191236) ;
0 commit comments