Skip to content

Commit b529b9a

Browse files
committed
Share the adoption report shared item calculation in Entity Framework
Reads the access graph as flat projections and defers counting to SharedItemCountCalculator, so the provider translations no longer materialise the member by cipher cross product. Covers the three reads the report is built from with SQLite integration tests against a real, throwaway database.
1 parent fbfc3b5 commit b529b9a

3 files changed

Lines changed: 452 additions & 44 deletions

File tree

Lines changed: 81 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using AutoMapper;
22
using Bit.Core.Dirt.Reports.Models.Data;
3+
using Bit.Core.Dirt.Reports.ReportFeatures;
34
using Bit.Core.Dirt.Reports.Repositories;
45
using Bit.Core.Enums;
56
using Bit.Core.Utilities;
@@ -28,37 +29,11 @@ public async Task<IReadOnlyList<MemberAdoptionReportDetail>> GetMemberAdoptionDe
2829
var dbContext = GetDatabaseContext(scope);
2930
dbContext.Database.SetCommandTimeout(ReportCommandTimeoutSeconds);
3031

31-
var sharedItemCounts = await GetSharedItemCountsByOrganizationUserIdAsync(dbContext, organizationId);
32+
var access = await GetMemberCollectionAccessAsync(dbContext, organizationId);
33+
var collectionCiphers = await GetCollectionCipherLinksAsync(dbContext, organizationId);
34+
var details = await GetConfirmedMemberDetailsAsync(dbContext, organizationId);
3235

33-
var details = await (
34-
from organizationUser in dbContext.OrganizationUsers
35-
where organizationUser.OrganizationId == organizationId
36-
&& organizationUser.Status == OrganizationUserStatusType.Confirmed
37-
join user in dbContext.Users
38-
on organizationUser.UserId equals (Guid?)user.Id into userJoin
39-
from user in userJoin.DefaultIfEmpty()
40-
orderby user.Email ?? organizationUser.Email, organizationUser.Id
41-
select new MemberAdoptionReportDetail
42-
{
43-
OrganizationUserId = organizationUser.Id,
44-
UserId = organizationUser.UserId,
45-
Name = user.Name,
46-
Email = user.Email ?? organizationUser.Email ?? string.Empty,
47-
LastActivityDate = dbContext.Devices
48-
.Where(device => device.UserId == organizationUser.UserId)
49-
.Max(device => device.LastActivityDate),
50-
HasExtensionInstalled = dbContext.Devices
51-
.Any(device => device.UserId == organizationUser.UserId
52-
&& DeviceTypes.BrowserExtensionTypes.Contains(device.Type)),
53-
VaultItemCount = dbContext.Ciphers
54-
.Count(cipher => cipher.UserId == organizationUser.UserId
55-
&& cipher.OrganizationId == null
56-
&& cipher.DeletedDate == null),
57-
HasRedeemedSponsorship = dbContext.OrganizationSponsorships
58-
.Any(sponsorship => sponsorship.SponsoringOrganizationUserId == organizationUser.Id
59-
&& sponsorship.SponsoredOrganizationId != null)
60-
})
61-
.ToListAsync();
36+
var sharedItemCounts = SharedItemCountCalculator.Calculate(access, collectionCiphers);
6237

6338
foreach (var detail in details)
6439
{
@@ -69,20 +44,21 @@ from user in userJoin.DefaultIfEmpty()
6944
}
7045

7146
/// <summary>
72-
/// Counts the distinct organization-owned ciphers each member can reach, directly or through their groups.
47+
/// Reads every member-to-collection edge in the organization, whether the grant is direct or
48+
/// inherited from a group.
7349
/// </summary>
74-
private static async Task<Dictionary<Guid, int>> GetSharedItemCountsByOrganizationUserIdAsync(
50+
internal static Task<List<MemberCollectionAccess>> GetMemberCollectionAccessAsync(
7551
DatabaseContext dbContext,
7652
Guid organizationId)
7753
{
78-
var directCollectionAccess =
54+
var directAccess =
7955
from collectionUser in dbContext.CollectionUsers
8056
join collection in dbContext.Collections
8157
on collectionUser.CollectionId equals collection.Id
8258
where collection.OrganizationId == organizationId
8359
select new { collectionUser.OrganizationUserId, collectionUser.CollectionId };
8460

85-
var groupCollectionAccess =
61+
var groupAccess =
8662
from groupUser in dbContext.GroupUsers
8763
join collectionGroup in dbContext.CollectionGroups
8864
on groupUser.GroupId equals collectionGroup.GroupId
@@ -91,17 +67,78 @@ on collectionGroup.CollectionId equals collection.Id
9167
where collection.OrganizationId == organizationId
9268
select new { groupUser.OrganizationUserId, collectionGroup.CollectionId };
9369

94-
return await (
95-
from access in directCollectionAccess.Union(groupCollectionAccess)
96-
join collectionCipher in dbContext.CollectionCiphers
97-
on access.CollectionId equals collectionCipher.CollectionId
70+
// Union, not Concat: a member who reaches one collection both directly and through one or more
71+
// groups is a single edge, and the database is the cheap place to collapse that. The projection
72+
// has to stay anonymous until after the set operation, because EF cannot translate a union whose
73+
// sides already project into MemberCollectionAccess.
74+
return directAccess
75+
.Union(groupAccess)
76+
.Select(edge => new MemberCollectionAccess(edge.OrganizationUserId, edge.CollectionId))
77+
.ToListAsync();
78+
}
79+
80+
/// <summary>
81+
/// Reads every collection-to-cipher edge in the organization, restricted to organization-owned
82+
/// ciphers that are not in the trash.
83+
/// </summary>
84+
internal static Task<List<CollectionCipherLink>> GetCollectionCipherLinksAsync(
85+
DatabaseContext dbContext,
86+
Guid organizationId)
87+
{
88+
// CollectionCipher is keyed on (CollectionId, CipherId), so these edges are already distinct.
89+
return (
90+
from collectionCipher in dbContext.CollectionCiphers
91+
join collection in dbContext.Collections
92+
on collectionCipher.CollectionId equals collection.Id
9893
join cipher in dbContext.Ciphers
9994
on collectionCipher.CipherId equals cipher.Id
100-
where cipher.OrganizationId == organizationId && cipher.DeletedDate == null
101-
select new { access.OrganizationUserId, cipher.Id })
102-
.Distinct()
103-
.GroupBy(reachableCipher => reachableCipher.OrganizationUserId)
104-
.Select(grouping => new { OrganizationUserId = grouping.Key, Count = grouping.Count() })
105-
.ToDictionaryAsync(result => result.OrganizationUserId, result => result.Count);
95+
where collection.OrganizationId == organizationId
96+
&& cipher.OrganizationId == organizationId
97+
&& cipher.DeletedDate == null
98+
select new CollectionCipherLink(collectionCipher.CollectionId, collectionCipher.CipherId))
99+
.ToListAsync();
100+
}
101+
102+
/// <summary>
103+
/// Reads one row per confirmed member with everything the report needs except the shared item count.
104+
/// </summary>
105+
internal static Task<List<MemberAdoptionReportDetail>> GetConfirmedMemberDetailsAsync(
106+
DatabaseContext dbContext,
107+
Guid organizationId)
108+
{
109+
return (
110+
from organizationUser in dbContext.OrganizationUsers
111+
where organizationUser.OrganizationId == organizationId
112+
&& organizationUser.Status == OrganizationUserStatusType.Confirmed
113+
join user in dbContext.Users
114+
on organizationUser.UserId equals (Guid?)user.Id into userJoin
115+
from user in userJoin.DefaultIfEmpty()
116+
// Sort on the coalesced email the report displays, so a member with no email either side
117+
// sorts as the empty string rather than as a null each provider places differently.
118+
orderby user.Email ?? organizationUser.Email ?? string.Empty, organizationUser.Id
119+
select new MemberAdoptionReportDetail
120+
{
121+
OrganizationUserId = organizationUser.Id,
122+
UserId = organizationUser.UserId,
123+
Name = user.Name,
124+
Email = user.Email ?? organizationUser.Email ?? string.Empty,
125+
LastActivityDate = dbContext.Devices
126+
.Where(device => device.UserId == organizationUser.UserId)
127+
.Max(device => device.LastActivityDate),
128+
HasExtensionInstalled = dbContext.Devices
129+
.Any(device => device.UserId == organizationUser.UserId
130+
&& DeviceTypes.BrowserExtensionTypes.Contains(device.Type)),
131+
// A confirmed member can still have no linked user. The null check keeps that member
132+
// from matching every unowned cipher, which EF's null semantics would otherwise do.
133+
VaultItemCount = dbContext.Ciphers
134+
.Count(cipher => cipher.UserId != null
135+
&& cipher.UserId == organizationUser.UserId
136+
&& cipher.OrganizationId == null
137+
&& cipher.DeletedDate == null),
138+
HasRedeemedSponsorship = dbContext.OrganizationSponsorships
139+
.Any(sponsorship => sponsorship.SponsoringOrganizationUserId == organizationUser.Id
140+
&& sponsorship.SponsoredOrganizationId != null)
141+
})
142+
.ToListAsync();
106143
}
107144
}

src/Infrastructure.EntityFramework/Infrastructure.EntityFramework.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,8 @@
2121
<ProjectReference Include="..\Core\Core.csproj" />
2222
<ProjectReference Include="..\Pam.Domain\Pam.Domain.csproj" />
2323
</ItemGroup>
24+
25+
<ItemGroup>
26+
<InternalsVisibleTo Include="Infrastructure.EFIntegration.Test" />
27+
</ItemGroup>
2428
</Project>

0 commit comments

Comments
 (0)