@@ -6,6 +6,7 @@ import type { MembershipRole } from "@calcom/prisma/enums";
66
77import type { IPermissionRepository } from "../../domain/repositories/IPermissionRepository" ;
88import type { PermissionString } from "../../domain/types/permission-registry" ;
9+ import { Resource } from "../../domain/types/permission-registry" ;
910import { PermissionCheckService } from "../permission-check.service" ;
1011import type { PermissionService } from "../permission.service" ;
1112
@@ -34,6 +35,7 @@ describe("PermissionCheckService", () => {
3435 checkRolePermission : vi . fn ( ) ,
3536 checkRolePermissions : vi . fn ( ) ,
3637 getResourcePermissions : vi . fn ( ) ,
38+ getResourcePermissionsByRoleId : vi . fn ( ) ,
3739 getTeamIdsWithPermission : vi . fn ( ) ,
3840 getTeamIdsWithPermissions : vi . fn ( ) ,
3941 } as MockRepository ;
@@ -78,6 +80,7 @@ describe("PermissionCheckService", () => {
7880 teamId : membership . teamId ,
7981 userId : membership . userId ,
8082 customRoleId : membership . customRoleId ,
83+ team : { parentId : null } ,
8184 } ) ;
8285 mockRepository . getOrgMembership . mockResolvedValueOnce ( null ) ;
8386 mockRepository . checkRolePermission . mockResolvedValueOnce ( true ) ;
@@ -192,6 +195,7 @@ describe("PermissionCheckService", () => {
192195 teamId : membership . teamId ,
193196 userId : membership . userId ,
194197 customRoleId : membership . customRoleId ,
198+ team : { parentId : null } ,
195199 } ) ;
196200 mockRepository . getOrgMembership . mockResolvedValueOnce ( null ) ;
197201 mockRepository . checkRolePermissions . mockResolvedValueOnce ( true ) ;
@@ -268,6 +272,7 @@ describe("PermissionCheckService", () => {
268272 teamId : membership . teamId ,
269273 userId : membership . userId ,
270274 customRoleId : membership . customRoleId ,
275+ team : { parentId : null } ,
271276 } ) ;
272277 mockRepository . getOrgMembership . mockResolvedValueOnce ( null ) ;
273278 mockRepository . checkRolePermissions . mockResolvedValueOnce ( false ) ;
@@ -379,4 +384,253 @@ describe("PermissionCheckService", () => {
379384 expect ( mockRepository . getTeamIdsWithPermissions ) . toHaveBeenCalledWith ( 1 , permissions ) ;
380385 } ) ;
381386 } ) ;
387+
388+ describe ( "getResourcePermissions" , ( ) => {
389+ it ( "should return empty array when PBAC is disabled" , async ( ) => {
390+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( false ) ;
391+
392+ const result = await service . getResourcePermissions ( {
393+ userId : 1 ,
394+ teamId : 1 ,
395+ resource : Resource . EventType ,
396+ } ) ;
397+
398+ expect ( result ) . toEqual ( [ ] ) ;
399+ expect ( mockFeaturesRepository . checkIfTeamHasFeature ) . toHaveBeenCalledWith ( 1 , "pbac" ) ;
400+ expect ( mockRepository . getMembershipByUserAndTeam ) . not . toHaveBeenCalled ( ) ;
401+ } ) ;
402+
403+ it ( "should return team-level permissions when PBAC is enabled and no org membership" , async ( ) => {
404+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
405+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( {
406+ id : 1 ,
407+ teamId : 1 ,
408+ userId : 1 ,
409+ customRoleId : "team_role" ,
410+ team : { parentId : null } ,
411+ } ) ;
412+ mockRepository . getOrgMembership . mockResolvedValueOnce ( null ) ;
413+ mockRepository . getResourcePermissionsByRoleId . mockResolvedValueOnce ( [ "create" , "read" ] ) ;
414+
415+ const result = await service . getResourcePermissions ( {
416+ userId : 1 ,
417+ teamId : 1 ,
418+ resource : Resource . EventType ,
419+ } ) ;
420+
421+ expect ( result ) . toEqual ( [ "eventType.create" , "eventType.read" ] ) ;
422+ expect ( mockRepository . getMembershipByUserAndTeam ) . toHaveBeenCalledWith ( 1 , 1 ) ;
423+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledWith (
424+ "team_role" ,
425+ Resource . EventType
426+ ) ;
427+ expect ( mockRepository . getOrgMembership ) . not . toHaveBeenCalled ( ) ;
428+ } ) ;
429+
430+ it ( "should return only team permissions when team has no parentId" , async ( ) => {
431+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
432+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( {
433+ id : 1 ,
434+ teamId : 1 ,
435+ userId : 1 ,
436+ customRoleId : "team_role" ,
437+ team : { parentId : null } ,
438+ } ) ;
439+ mockRepository . getResourcePermissionsByRoleId . mockResolvedValueOnce ( [ "create" , "read" , "update" ] ) ;
440+
441+ const result = await service . getResourcePermissions ( {
442+ userId : 1 ,
443+ teamId : 1 ,
444+ resource : Resource . EventType ,
445+ } ) ;
446+
447+ expect ( result ) . toEqual ( [ "eventType.create" , "eventType.read" , "eventType.update" ] ) ;
448+ expect ( mockRepository . getMembershipByUserAndTeam ) . toHaveBeenCalledWith ( 1 , 1 ) ;
449+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledTimes ( 1 ) ;
450+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledWith (
451+ "team_role" ,
452+ Resource . EventType
453+ ) ;
454+ expect ( mockRepository . getOrgMembership ) . not . toHaveBeenCalled ( ) ;
455+ } ) ;
456+
457+ it ( "should return combined team and org permissions when both exist" , async ( ) => {
458+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
459+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( {
460+ id : 1 ,
461+ teamId : 1 ,
462+ userId : 1 ,
463+ customRoleId : "team_role" ,
464+ team : { parentId : 2 } ,
465+ } ) ;
466+ mockRepository . getOrgMembership . mockResolvedValueOnce ( {
467+ id : 2 ,
468+ teamId : 2 ,
469+ userId : 1 ,
470+ customRoleId : "org_role" ,
471+ } ) ;
472+
473+ // Mock team permissions - create implies read
474+ mockRepository . getResourcePermissionsByRoleId
475+ . mockResolvedValueOnce ( [ "create" , "read" ] ) // team permissions
476+ . mockResolvedValueOnce ( [ "update" , "delete" , "read" ] ) ; // org permissions - update/delete imply read
477+
478+ const result = await service . getResourcePermissions ( {
479+ userId : 1 ,
480+ teamId : 1 ,
481+ resource : Resource . EventType ,
482+ } ) ;
483+
484+ expect ( result ) . toEqual ( [ "eventType.create" , "eventType.read" , "eventType.update" , "eventType.delete" ] ) ;
485+ expect ( mockRepository . getMembershipByUserAndTeam ) . toHaveBeenCalledWith ( 1 , 1 ) ;
486+ expect ( mockRepository . getOrgMembership ) . toHaveBeenCalledWith ( 1 , 2 ) ;
487+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledTimes ( 2 ) ;
488+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenNthCalledWith (
489+ 1 ,
490+ "team_role" ,
491+ Resource . EventType
492+ ) ;
493+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenNthCalledWith (
494+ 2 ,
495+ "org_role" ,
496+ Resource . EventType
497+ ) ;
498+ } ) ;
499+
500+ it ( "should deduplicate permissions when team and org have overlapping permissions" , async ( ) => {
501+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
502+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( {
503+ id : 1 ,
504+ teamId : 1 ,
505+ userId : 1 ,
506+ customRoleId : "team_role" ,
507+ team : { parentId : 2 } ,
508+ } ) ;
509+ mockRepository . getOrgMembership . mockResolvedValueOnce ( {
510+ id : 2 ,
511+ teamId : 2 ,
512+ userId : 1 ,
513+ customRoleId : "org_role" ,
514+ } ) ;
515+
516+ // Mock overlapping permissions - both include read, update implies read
517+ mockRepository . getResourcePermissionsByRoleId
518+ . mockResolvedValueOnce ( [ "create" , "read" ] ) // team permissions - create implies read
519+ . mockResolvedValueOnce ( [ "read" , "update" ] ) ; // org permissions - update implies read, explicit read
520+
521+ const result = await service . getResourcePermissions ( {
522+ userId : 1 ,
523+ teamId : 1 ,
524+ resource : Resource . EventType ,
525+ } ) ;
526+
527+ expect ( result ) . toEqual ( [ "eventType.create" , "eventType.read" , "eventType.update" ] ) ;
528+ expect ( result ) . toHaveLength ( 3 ) ; // Should not have duplicate "read"
529+ } ) ;
530+
531+ it ( "should return only org permissions when team has no custom role" , async ( ) => {
532+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
533+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( {
534+ id : 1 ,
535+ teamId : 1 ,
536+ userId : 1 ,
537+ customRoleId : null ,
538+ team : { parentId : 2 } ,
539+ } ) ;
540+ mockRepository . getOrgMembership . mockResolvedValueOnce ( {
541+ id : 2 ,
542+ teamId : 2 ,
543+ userId : 1 ,
544+ customRoleId : "org_role" ,
545+ } ) ;
546+
547+ // Update and delete imply read access
548+ mockRepository . getResourcePermissionsByRoleId . mockResolvedValueOnce ( [ "update" , "delete" , "read" ] ) ;
549+
550+ const result = await service . getResourcePermissions ( {
551+ userId : 1 ,
552+ teamId : 1 ,
553+ resource : Resource . EventType ,
554+ } ) ;
555+
556+ expect ( result ) . toEqual ( [ "eventType.update" , "eventType.delete" , "eventType.read" ] ) ;
557+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledTimes ( 1 ) ;
558+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledWith (
559+ "org_role" ,
560+ Resource . EventType
561+ ) ;
562+ } ) ;
563+
564+ it ( "should return empty array when no membership found" , async ( ) => {
565+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
566+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( null ) ;
567+
568+ const result = await service . getResourcePermissions ( {
569+ userId : 1 ,
570+ teamId : 1 ,
571+ resource : Resource . EventType ,
572+ } ) ;
573+
574+ expect ( result ) . toEqual ( [ ] ) ;
575+ expect ( mockRepository . getResourcePermissionsByRoleId ) . not . toHaveBeenCalled ( ) ;
576+ } ) ;
577+
578+ it ( "should return empty array and log error when repository throws" , async ( ) => {
579+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
580+ mockRepository . getMembershipByUserAndTeam . mockRejectedValueOnce ( new Error ( "Database error" ) ) ;
581+
582+ const result = await service . getResourcePermissions ( {
583+ userId : 1 ,
584+ teamId : 1 ,
585+ resource : Resource . EventType ,
586+ } ) ;
587+
588+ expect ( result ) . toEqual ( [ ] ) ;
589+ } ) ;
590+
591+ it ( "should enforce correct hierarchy - org permissions should take precedence over team permissions" , async ( ) => {
592+ mockFeaturesRepository . checkIfTeamHasFeature . mockResolvedValueOnce ( true ) ;
593+ mockRepository . getMembershipByUserAndTeam . mockResolvedValueOnce ( {
594+ id : 1 ,
595+ teamId : 1 ,
596+ userId : 1 ,
597+ customRoleId : "admin_team_role" ,
598+ team : { parentId : 2 } ,
599+ } ) ;
600+ mockRepository . getOrgMembership . mockResolvedValueOnce ( {
601+ id : 2 ,
602+ teamId : 2 ,
603+ userId : 1 ,
604+ customRoleId : "restricted_org_role" ,
605+ } ) ;
606+
607+ // Team role has broad permissions - CUD actions include read
608+ // Org role has restricted permissions (only read)
609+ mockRepository . getResourcePermissionsByRoleId
610+ . mockResolvedValueOnce ( [ "create" , "read" , "update" ] ) // team permissions - CRU
611+ . mockResolvedValueOnce ( [ "delete" ] ) ; // org permissions - delete only
612+
613+ const result = await service . getResourcePermissions ( {
614+ userId : 1 ,
615+ teamId : 1 ,
616+ resource : Resource . EventType ,
617+ } ) ;
618+
619+ // User gets eventType.delete because they have this in the org
620+ expect ( result ) . toEqual ( [ "eventType.create" , "eventType.read" , "eventType.update" , "eventType.delete" ] ) ;
621+
622+ // Verify both team and org permissions were fetched
623+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenCalledTimes ( 2 ) ;
624+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenNthCalledWith (
625+ 1 ,
626+ "admin_team_role" ,
627+ Resource . EventType
628+ ) ;
629+ expect ( mockRepository . getResourcePermissionsByRoleId ) . toHaveBeenNthCalledWith (
630+ 2 ,
631+ "restricted_org_role" ,
632+ Resource . EventType
633+ ) ;
634+ } ) ;
635+ } ) ;
382636} ) ;
0 commit comments