22using GovUK . Dfe . FlexForms . Domain . Common ;
33using GovUK . Dfe . FlexForms . Domain . Entities ;
44using GovUK . Dfe . FlexForms . Domain . Interfaces . Repositories ;
5+ using GovUK . Dfe . FlexForms . Domain . Tenancy ;
56using GovUK . Dfe . FlexForms . Domain . ValueObjects ;
67using Microsoft . EntityFrameworkCore ;
7- using ApplicationId = GovUK . Dfe . FlexForms . Domain . ValueObjects . ApplicationId ;
88
99namespace GovUK . Dfe . FlexForms . Application . Services ;
1010
1111/// <inheritdoc />
1212public sealed class TenantPermissionFilter (
1313 ITenantTemplateCatalogue tenantTemplateCatalogue ,
14- IApplicationRepository applicationRepository ) : ITenantPermissionFilter
14+ IApplicationRepository applicationRepository ,
15+ ITenantContextAccessor tenantContextAccessor ) : ITenantPermissionFilter
1516{
1617 /// <inheritdoc />
1718 public async Task < IReadOnlyList < Permission > > FilterToCurrentTenantAsync (
1819 IEnumerable < Permission > permissions ,
1920 CancellationToken cancellationToken = default )
2021 {
22+ var currentTenantId = tenantContextAccessor . CurrentTenant ? . Id ;
23+ if ( currentTenantId is null )
24+ return Array . Empty < Permission > ( ) ;
25+
2126 var tenantTemplateIds = ( await tenantTemplateCatalogue . GetTemplateIdsAsync ( cancellationToken ) )
27+ . Select ( id => id . Value )
2228 . ToHashSet ( ) ;
2329
2430 if ( tenantTemplateIds . Count == 0 )
@@ -28,23 +34,66 @@ public async Task<IReadOnlyList<Permission>> FilterToCurrentTenantAsync(
2834 if ( permissionList . Count == 0 )
2935 return permissionList ;
3036
31- var applicationTemplateMap = await BuildApplicationTemplateMapAsync ( permissionList , cancellationToken ) ;
37+ var applicationOwnership = await BuildApplicationOwnershipMapAsync (
38+ permissionList ,
39+ cancellationToken ) ;
3240
3341 return permissionList
34- . Where ( p => BelongsToTenant ( p , tenantTemplateIds , applicationTemplateMap ) )
42+ . Where ( p => BelongsToTenant (
43+ p ,
44+ currentTenantId . Value ,
45+ tenantTemplateIds ,
46+ applicationOwnership ) )
3547 . ToList ( ) ;
3648 }
3749
3850 /// <inheritdoc />
39- public Task < bool > ApplicationBelongsToCurrentTenantAsync (
40- TemplateId templateId ,
51+ public async Task < bool > ApplicationBelongsToCurrentTenantAsync (
52+ Guid applicationId ,
4153 CancellationToken cancellationToken = default )
42- => tenantTemplateCatalogue . ContainsAsync ( templateId , cancellationToken ) ;
54+ {
55+ var currentTenantId = tenantContextAccessor . CurrentTenant ? . Id ;
56+ if ( currentTenantId is null || applicationId == Guid . Empty )
57+ return false ;
58+
59+ var tenantTemplateIds = ( await tenantTemplateCatalogue . GetTemplateIdsAsync ( cancellationToken ) )
60+ . Select ( id => id . Value )
61+ . ToHashSet ( ) ;
62+
63+ if ( tenantTemplateIds . Count == 0 )
64+ return false ;
4365
66+ var ownership = await applicationRepository . Query ( )
67+ . AsNoTracking ( )
68+ . Where ( a => a . Id != null && a . Id . Value == applicationId )
69+ . Select ( a => new
70+ {
71+ TemplateId = a . TemplateVersion ! . TemplateId . Value ,
72+ TemplateTenantId = a . TemplateVersion ! . Template ! . TenantId
73+ } )
74+ . FirstOrDefaultAsync ( cancellationToken ) ;
75+
76+ if ( ownership is null )
77+ return false ;
78+
79+ return IsTemplateInTenant (
80+ ownership . TemplateId ,
81+ ownership . TemplateTenantId ,
82+ currentTenantId . Value ,
83+ tenantTemplateIds ) ;
84+ }
85+
86+ /// <summary>
87+ /// Application/template grants belong to the current tenant when their template is in the
88+ /// tenant catalogue and, when the template is tenant-owned, the owning tenant matches.
89+ /// This prevents HostMappings overlap from leaking another tenant's application grants
90+ /// into the permissions list.
91+ /// </summary>
4492 internal static bool BelongsToTenant (
4593 Permission permission ,
46- HashSet < TemplateId > tenantTemplateIds ,
47- IReadOnlyDictionary < Guid , TemplateId > applicationTemplateMap )
94+ Guid currentTenantId ,
95+ HashSet < Guid > tenantTemplateIds ,
96+ IReadOnlyDictionary < Guid , ApplicationOwnership > applicationOwnership )
4897 {
4998 switch ( permission . ResourceType )
5099 {
@@ -53,21 +102,24 @@ internal static bool BelongsToTenant(
53102 return tenantTemplateIds . Count > 0 ;
54103
55104 return Guid . TryParse ( permission . ResourceKey , out var templateGuid )
56- && tenantTemplateIds . Contains ( new TemplateId ( templateGuid ) ) ;
105+ && IsTemplateInTenant ( templateGuid , owningTenantId : null , currentTenantId , tenantTemplateIds ) ;
57106
58107 case ResourceType . Application :
59108 case ResourceType . ApplicationFiles :
60109 if ( IsAnyKey ( permission . ResourceKey ) )
61110 return tenantTemplateIds . Count > 0 ;
62111
63- if ( permission . Application ? . TemplateVersion ? . TemplateId is { } loadedTemplateId )
64- return tenantTemplateIds . Contains ( loadedTemplateId ) ;
112+ if ( ! TryResolveApplicationId ( permission , out var applicationGuid ) )
113+ return false ;
65114
66- if ( ! Guid . TryParse ( permission . ResourceKey , out var applicationGuid ) )
115+ if ( ! applicationOwnership . TryGetValue ( applicationGuid , out var ownership ) )
67116 return false ;
68117
69- return applicationTemplateMap . TryGetValue ( applicationGuid , out var mappedTemplateId )
70- && tenantTemplateIds . Contains ( mappedTemplateId ) ;
118+ return IsTemplateInTenant (
119+ ownership . TemplateId ,
120+ ownership . TemplateTenantId ,
121+ currentTenantId ,
122+ tenantTemplateIds ) ;
71123
72124 case ResourceType . User :
73125 case ResourceType . Notifications :
@@ -76,37 +128,66 @@ internal static bool BelongsToTenant(
76128 }
77129 }
78130
79- private async Task < IReadOnlyDictionary < Guid , TemplateId > > BuildApplicationTemplateMapAsync (
131+ internal static bool IsTemplateInTenant (
132+ Guid templateId ,
133+ Guid ? owningTenantId ,
134+ Guid currentTenantId ,
135+ HashSet < Guid > tenantTemplateIds )
136+ {
137+ if ( ! tenantTemplateIds . Contains ( templateId ) )
138+ return false ;
139+
140+ // Tenant-owned templates are authoritative: never treat another tenant's owned
141+ // template as belonging here just because it also appears in HostMappings.
142+ if ( owningTenantId is Guid owner && owner != currentTenantId )
143+ return false ;
144+
145+ return true ;
146+ }
147+
148+ private async Task < IReadOnlyDictionary < Guid , ApplicationOwnership > > BuildApplicationOwnershipMapAsync (
80149 IReadOnlyCollection < Permission > permissions ,
81150 CancellationToken cancellationToken )
82151 {
83- var applicationIds = permissions
152+ var applicationGuids = permissions
84153 . Where ( p => p . ResourceType is ResourceType . Application or ResourceType . ApplicationFiles )
85- . Where ( p => ! IsAnyKey ( p . ResourceKey ) )
86- . Select ( p => Guid . TryParse ( p . ResourceKey , out var id ) ? id : Guid . Empty )
154+ . Select ( p => TryResolveApplicationId ( p , out var id ) ? id : Guid . Empty )
87155 . Where ( id => id != Guid . Empty )
88156 . Distinct ( )
89- . Select ( id => new ApplicationId ( id ) )
90157 . ToList ( ) ;
91158
92- if ( applicationIds . Count == 0 )
93- return new Dictionary < Guid , TemplateId > ( ) ;
159+ if ( applicationGuids . Count == 0 )
160+ return new Dictionary < Guid , ApplicationOwnership > ( ) ;
94161
95162 var rows = await applicationRepository . Query ( )
96163 . AsNoTracking ( )
97- . Where ( a => applicationIds . Contains ( a . Id ! ) )
164+ . Where ( a => a . Id != null && applicationGuids . Contains ( a . Id . Value ) )
98165 . Select ( a => new
99166 {
100167 ApplicationId = a . Id ! . Value ,
101- TemplateId = a . TemplateVersion ! . TemplateId
168+ TemplateId = a . TemplateVersion ! . TemplateId . Value ,
169+ TemplateTenantId = a . TemplateVersion ! . Template ! . TenantId
102170 } )
103171 . ToListAsync ( cancellationToken ) ;
104172
105173 return rows . ToDictionary (
106174 row => row . ApplicationId ,
107- row => row . TemplateId ) ;
175+ row => new ApplicationOwnership ( row . TemplateId , row . TemplateTenantId ) ) ;
176+ }
177+
178+ private static bool TryResolveApplicationId ( Permission permission , out Guid applicationId )
179+ {
180+ if ( permission . ApplicationId is not null )
181+ {
182+ applicationId = permission . ApplicationId . Value ;
183+ return applicationId != Guid . Empty ;
184+ }
185+
186+ return Guid . TryParse ( permission . ResourceKey , out applicationId ) && applicationId != Guid . Empty ;
108187 }
109188
110189 private static bool IsAnyKey ( string resourceKey ) =>
111190 string . Equals ( resourceKey , PermissionConstants . AnyResourceKey , StringComparison . OrdinalIgnoreCase ) ;
191+
192+ internal readonly record struct ApplicationOwnership ( Guid TemplateId , Guid ? TemplateTenantId ) ;
112193}
0 commit comments