@@ -18,7 +18,13 @@ const ctx = vi.hoisted(() => ({ prisma: undefined as unknown as PrismaClient }))
1818const mocks = vi . hoisted ( ( ) => {
1919 const assertSourcePatActive = vi . fn < ( ...args : any [ ] ) => Promise < boolean > > ( ) ;
2020 assertSourcePatActive . mockResolvedValue ( true ) ;
21- return { assertSourcePatActive } ;
21+ const resolveDashboardAgentRepoSnapshot = vi . fn ( async ( projectId : string ) => ( {
22+ owner : "acme" ,
23+ repo : projectId ,
24+ sha : "a" . repeat ( 40 ) ,
25+ tarballUrl : `https://codeload.example/${ projectId } ` ,
26+ } ) ) ;
27+ return { assertSourcePatActive, resolveDashboardAgentRepoSnapshot } ;
2228} ) ;
2329
2430vi . mock ( "~/db.server" , ( ) => {
@@ -65,12 +71,7 @@ vi.mock("~/services/dashboardAgent.server", async (importOriginal) => {
6571 const actual = await importOriginal < typeof import ( "~/services/dashboardAgent.server" ) > ( ) ;
6672 return {
6773 ...actual ,
68- resolveDashboardAgentRepoSnapshot : async ( projectId : string ) => ( {
69- owner : "acme" ,
70- repo : projectId ,
71- sha : "a" . repeat ( 40 ) ,
72- tarballUrl : `https://codeload.example/${ projectId } ` ,
73- } ) ,
74+ resolveDashboardAgentRepoSnapshot : mocks . resolveDashboardAgentRepoSnapshot ,
7475 } ;
7576} ) ;
7677vi . mock ( "~/services/personalAccessToken.server" , async ( importOriginal ) => {
@@ -85,13 +86,27 @@ vi.mock("~/services/personalAccessToken.server", async (importOriginal) => {
8586} ) ;
8687
8788let patBearerUserId = "" ;
89+ // The OSS fallback's ability is otherwise purely cap-driven (UAT) or permissive (PAT) — there's
90+ // no real path to a role that fails a specific `can()` check. Set this to force one, for the one
91+ // case that needs it; `undefined` (the default) leaves the real ability untouched.
92+ let forcedAbilityCan : ( ( action : string , resource : unknown ) => boolean ) | undefined ;
8893
8994// The real fallback's `authenticatePat` looks up a real PAT row by hash, which the synthetic
9095// `tr_pat_e2e_token` bearer used below has none of. Only that lookup is stubbed; every UAT check
91- // (`authenticateUserActor`) stays real.
96+ // (`authenticateUserActor`) stays real, aside from the opt-in ability override above .
9297vi . mock ( "~/services/rbac.server" , async ( importOriginal ) => {
9398 const actual = await importOriginal < typeof import ( "~/services/rbac.server" ) > ( ) ;
9499 // Patched in place (not spread): the plugin's methods are bound to its own instance.
100+ const realAuthenticateUserActor = actual . rbac . authenticateUserActor . bind ( actual . rbac ) ;
101+ actual . rbac . authenticateUserActor = async (
102+ ...args : Parameters < typeof realAuthenticateUserActor >
103+ ) => {
104+ const result = await realAuthenticateUserActor ( ...args ) ;
105+ if ( result . ok && forcedAbilityCan ) {
106+ return { ...result , ability : { ...result . ability , can : forcedAbilityCan } } ;
107+ }
108+ return result ;
109+ } ;
95110 actual . rbac . authenticatePat = async ( ) => ( {
96111 ok : true as const ,
97112 userId : patBearerUserId ,
@@ -173,7 +188,7 @@ async function seedWorld(prisma: PrismaClient) {
173188 data : { organizationId : orgB . id , userId : crossMember . id , role : "ADMIN" } ,
174189 } ) ;
175190
176- async function projectWithEnvironments ( name : string , devOwnerMembershipId : string ) {
191+ async function projectWithEnvironments ( name : string , extraDevOwnerMembershipId ? : string ) {
177192 const projectSlug = `${ slug } _${ name } ` ;
178193 const project = await prisma . project . create ( {
179194 data : {
@@ -196,19 +211,30 @@ async function seedWorld(prisma: PrismaClient) {
196211 ...data ,
197212 projectId : project . id ,
198213 organizationId : orgA . id ,
199- apiKey : `tr_${ data . slug } _${ projectSlug } ` ,
200- pkApiKey : `pk_${ data . slug } _${ projectSlug } ` ,
214+ // Two dev rows can share a slug (unique key is projectId+slug+orgMemberId), so the key
215+ // needs its own uniqueness source too.
216+ apiKey : `tr_${ data . slug } _${ projectSlug } _${ suffix ( ) } ` ,
217+ pkApiKey : `pk_${ data . slug } _${ projectSlug } _${ suffix ( ) } ` ,
201218 shortcode : `${ data . slug } ${ suffix ( ) } ` ,
202219 } ,
203220 } ) ;
204221
205222 const prod = await envFor ( { slug : "prod" , type : "PRODUCTION" } ) ;
206223 const staging = await envFor ( { slug : "stg" , type : "STAGING" } ) ;
207- const dev = await envFor ( {
208- slug : "dev" ,
224+ // The caller's own dev, on every project — org membership never substitutes for it.
225+ const dev = await envFor ( { slug : "dev" , type : "DEVELOPMENT" , orgMemberId : memberOfA . id } ) ;
226+ const devBranch = await envFor ( {
227+ slug : `dev-feat-${ name } ` ,
209228 type : "DEVELOPMENT" ,
210- orgMemberId : devOwnerMembershipId ,
229+ branchName : `feat/${ name } ` ,
230+ parentEnvironmentId : dev . id ,
231+ orgMemberId : memberOfA . id ,
211232 } ) ;
233+ // A second member's own dev, same slug — the unique key is (project, slug, orgMember), so
234+ // both rows coexist. Org membership doesn't hand this one over to the caller.
235+ const otherDev = extraDevOwnerMembershipId
236+ ? await envFor ( { slug : "dev" , type : "DEVELOPMENT" , orgMemberId : extraDevOwnerMembershipId } )
237+ : undefined ;
212238 const previewParent = await envFor ( { slug : "preview" , type : "PREVIEW" } ) ;
213239 const previewBranch = await envFor ( {
214240 slug : `preview-feat-${ name } ` ,
@@ -265,6 +291,7 @@ async function seedWorld(prisma: PrismaClient) {
265291 const prodPromotion = await promote ( prod , `${ name } -prod-task` ) ;
266292 const stagingPromotion = await promote ( staging , `${ name } -staging-task` ) ;
267293 await promote ( previewBranch , `${ name } -preview-task` ) ;
294+ await promote ( devBranch , `${ name } -dev-branch-task` ) ;
268295
269296 async function runOn (
270297 environment : { id : string } ,
@@ -298,10 +325,21 @@ async function seedWorld(prisma: PrismaClient) {
298325 const run = await runOn ( prod , "PRODUCTION" , prodPromotion ) ;
299326 const stagingRun = await runOn ( staging , "STAGING" , stagingPromotion ) ;
300327
301- return { project, prod, staging, dev, previewParent, previewBranch, run, stagingRun } ;
328+ return {
329+ project,
330+ prod,
331+ staging,
332+ dev,
333+ devBranch,
334+ otherDev,
335+ previewParent,
336+ previewBranch,
337+ run,
338+ stagingRun,
339+ } ;
302340 }
303341
304- const current = await projectWithEnvironments ( "current" , memberOfA . id ) ;
342+ const current = await projectWithEnvironments ( "current" ) ;
305343 const sibling = await projectWithEnvironments ( "sibling" , otherOfA . id ) ;
306344
307345 const bProject = await prisma . project . create ( {
@@ -451,6 +489,7 @@ const projects = (opts: { token: string; organizationId?: string }) =>
451489beforeEach ( ( ) => {
452490 mocks . assertSourcePatActive . mockReset ( ) ;
453491 mocks . assertSourcePatActive . mockResolvedValue ( true ) ;
492+ forcedAbilityCan = undefined ;
454493} ) ;
455494
456495describe ( "an org-wide token reaches a sibling project across every UAT route" , ( ) => {
@@ -555,6 +594,18 @@ describe("an org-wide token reaches a sibling project across every UAT route", (
555594 } ) ;
556595 expect ( scopedSibling . status ) . toBe ( 403 ) ;
557596 expect ( scopedSibling . body . code ) . toBe ( "forbidden_environment" ) ;
597+
598+ // An org claim naming the right org still needs membership of it.
599+ const outsiderToken = await mintUat ( {
600+ userId : world . stranger . id ,
601+ organizationId : world . orgA . id ,
602+ } ) ;
603+ const outsider = await route . callSibling ( {
604+ token : outsiderToken ,
605+ projectRef : world . sibling . project . externalRef ,
606+ env : "prod" ,
607+ } ) ;
608+ expect ( outsider . status ) . toBe ( 404 ) ;
558609 }
559610
560611 // Route-specific extras: a preview branch of the sibling project, and another member's dev
@@ -584,15 +635,23 @@ describe("an org-wide token reaches a sibling project across every UAT route", (
584635 expect ( branchSnapshot . status ) . toBe ( 200 ) ;
585636 expect ( branchSnapshot . body . repo ) . toBe ( world . sibling . project . id ) ;
586637
638+ // Another member's dev env: org membership doesn't hand it over. `crossMember` has no dev
639+ // row of their own on "sibling" — `member` now does, so this uses the one caller who can
640+ // prove that without accidentally hitting their own row instead.
641+ const noDevToken = await mintUat ( {
642+ userId : world . crossMember . id ,
643+ organizationId : world . orgA . id ,
644+ environmentId : world . current . prod . id ,
645+ } ) ;
587646 const otherDevWorker = await worker ( {
588- token,
647+ token : noDevToken ,
589648 projectRef : world . sibling . project . externalRef ,
590649 env : "dev" ,
591650 } ) ;
592651 expect ( otherDevWorker . status ) . toBe ( 404 ) ;
593652
594653 const otherDevSnapshot = await snapshot ( {
595- token,
654+ token : noDevToken ,
596655 projectRef : world . sibling . project . externalRef ,
597656 env : "dev" ,
598657 } ) ;
@@ -629,22 +688,44 @@ postgresTest(
629688 } ;
630689 const token = await mintUat ( minted ) ;
631690
632- // Every environment of a sibling project, dev included — none of them the token's own.
691+ // Every environment of a sibling project, dev included — the caller's own dev row, not the
692+ // second member's, and none of them the token's own environment.
633693 const sibling = await environments ( { token, projectRef : world . sibling . project . externalRef } ) ;
634694 expect ( sibling . status ) . toBe ( 200 ) ;
635- // The member's own dev env lives on "current", not "sibling" — dev is per-user.
636- expect ( sibling . body . map ( ( e : any ) => e . slug ) . sort ( ) ) . toEqual ( [ "preview" , "prod" , "stg" ] ) ;
695+ expect ( sibling . body . map ( ( e : any ) => e . slug ) . sort ( ) ) . toEqual ( [ "dev" , "preview" , "prod" , "stg" ] ) ;
637696
638697 // Its own project answers the same way: with an org claim, the org is the boundary.
639698 const own = await environments ( { token, projectRef : world . current . project . externalRef } ) ;
640699 expect ( own . status ) . toBe ( 200 ) ;
641700 expect ( own . body . map ( ( e : any ) => e . slug ) . sort ( ) ) . toEqual ( [ "dev" , "preview" , "prod" , "stg" ] ) ;
642701
643- // A project outside the claimed organization.
644- // The RBAC plugin's own membership floor turns this away before the route's org-scope
645- // check ever runs, so this proves the same boundary one layer earlier.
646- const foreign = await environments ( { token, projectRef : world . bProject . externalRef } ) ;
702+ // A project outside the claimed organization — routed through a member of both orgs so the
703+ // RBAC plugin's own membership floor doesn't intercept first; this exercises the route's own
704+ // org-scope check and its error shape.
705+ const crossEnvToken = await mintUat ( {
706+ userId : world . crossMember . id ,
707+ organizationId : world . orgA . id ,
708+ } ) ;
709+ const foreign = await environments ( {
710+ token : crossEnvToken ,
711+ projectRef : world . bProject . externalRef ,
712+ } ) ;
647713 expect ( foreign . status ) . toBe ( 403 ) ;
714+ expect ( foreign . body . code ) . toBe ( "forbidden_environment" ) ;
715+
716+ // An org claim naming the right org still needs membership of it. This route resolves its
717+ // org before authenticating (its `context` looks the project up directly), so the RBAC
718+ // plugin's own membership floor turns a non-member away here — one layer earlier than the
719+ // route's own `findProjectByRef` check would.
720+ const outsiderToken = await mintUat ( {
721+ userId : world . stranger . id ,
722+ organizationId : world . orgA . id ,
723+ } ) ;
724+ const outsider = await environments ( {
725+ token : outsiderToken ,
726+ projectRef : world . current . project . externalRef ,
727+ } ) ;
728+ expect ( outsider . status ) . toBe ( 403 ) ;
648729
649730 // An environment claim with no org claim: that environment only, nothing in another project.
650731 const envOnlyToken = await mintUat ( {
@@ -745,6 +826,20 @@ postgresTest(
745826 const foreign = await jwt ( { token, projectRef : world . bProject . externalRef , env : "prod" } ) ;
746827 expect ( foreign . status ) . toBe ( 404 ) ;
747828
829+ // A member of both orgs still can't reach the other org's environment with an orgA claim —
830+ // membership resolves the project, so this proves the claim's own org boundary.
831+ const crossToken = await mintUat ( {
832+ userId : world . crossMember . id ,
833+ organizationId : world . orgA . id ,
834+ } ) ;
835+ const crossForeign = await jwt ( {
836+ token : crossToken ,
837+ projectRef : world . bProject . externalRef ,
838+ env : "prod" ,
839+ } ) ;
840+ expect ( crossForeign . status ) . toBe ( 403 ) ;
841+ expect ( crossForeign . body . code ) . toBe ( "forbidden_environment" ) ;
842+
748843 // A non-member of the claimed org.
749844 const outsiderToken = await mintUat ( {
750845 userId : world . stranger . id ,
@@ -815,7 +910,8 @@ postgresTest(
815910 await expect (
816911 assertUserActorEnvironmentAccess ( undefined , world . current . prod )
817912 ) . resolves . toBeUndefined ( ) ;
818- }
913+ } ,
914+ 60_000
819915) ;
820916
821917describe ( "a user-actor token's environment scope, driven through every route" , ( ) => {
@@ -925,6 +1021,13 @@ describe("a user-actor token's environment scope, driven through every route", (
9251021 target : world . current . previewBranch ,
9261022 fallsBackTo : world . current . previewParent ,
9271023 } ,
1024+ {
1025+ name : "a development branch" ,
1026+ env : "dev" ,
1027+ branch : "feat/current" ,
1028+ target : world . current . devBranch ,
1029+ fallsBackTo : world . current . dev ,
1030+ } ,
9281031 ] ;
9291032
9301033 for ( const { name, env, branch, target, fallsBackTo } of CASES ) {
@@ -1141,6 +1244,29 @@ postgresTest(
11411244 60_000
11421245) ;
11431246
1247+ postgresTest (
1248+ "repo-snapshot authorization denies a role the ability itself refuses" ,
1249+ async ( { prisma } ) => {
1250+ ctx . prisma = prisma ;
1251+ const world = await seedWorld ( prisma ) ;
1252+ // The OSS fallback's ability is otherwise cap-driven (UAT) or permissive (PAT), so a role
1253+ // that fails `can()` independent of cap is forced here rather than reachable for real.
1254+ forcedAbilityCan = ( ) => false ;
1255+ mocks . resolveDashboardAgentRepoSnapshot . mockClear ( ) ;
1256+
1257+ const token = await mintUat ( { userId : world . member . id , environmentId : world . current . prod . id } ) ;
1258+ const denied = await snapshot ( {
1259+ token,
1260+ projectRef : world . current . project . externalRef ,
1261+ env : "prod" ,
1262+ } ) ;
1263+
1264+ expect ( denied . status ) . toBe ( 403 ) ;
1265+ expect ( mocks . resolveDashboardAgentRepoSnapshot ) . not . toHaveBeenCalled ( ) ;
1266+ } ,
1267+ 60_000
1268+ ) ;
1269+
11441270describe ( "resolveAgentTokenScope" , ( ) => {
11451271 it ( "pins an environment-only token and ignores the request" , ( ) => {
11461272 const scope = resolveAgentTokenScope (
0 commit comments