-
Notifications
You must be signed in to change notification settings - Fork 1.8k
Read member adoption details for an organization #8315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
maxkpower
wants to merge
4
commits into
dirt/prototype/adoption-report/device-index
from
dirt/prototype/adoption-report/data-access
Draft
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
573f414
Read member adoption details for an organization
maxkpower b583477
Count adoption report shared items in memory
maxkpower e40f6b2
Read the adoption report access graph instead of aggregating in SQL
maxkpower dfe1bf2
Share the adoption report shared item calculation in Entity Framework
maxkpower File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
11 changes: 11 additions & 0 deletions
11
src/Core/Dirt/Models/Data/MemberAdoptionReportAccessGraph.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| namespace Bit.Core.Dirt.Reports.Models.Data; | ||
|
|
||
| /// <summary> | ||
| /// A member's access to one collection, whether granted directly or through a group. | ||
| /// </summary> | ||
| public readonly record struct MemberCollectionAccess(Guid OrganizationUserId, Guid CollectionId); | ||
|
|
||
| /// <summary> | ||
| /// An organization-owned, non-deleted cipher's membership of one collection. | ||
| /// </summary> | ||
| public readonly record struct CollectionCipherLink(Guid CollectionId, Guid CipherId); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| namespace Bit.Core.Dirt.Reports.Models.Data; | ||
|
|
||
| public class MemberAdoptionReportDetail | ||
| { | ||
| public Guid OrganizationUserId { get; set; } | ||
| public Guid? UserId { get; set; } | ||
| public string? Name { get; set; } | ||
| public string Email { get; set; } = string.Empty; | ||
| public DateTime? LastActivityDate { get; set; } | ||
| public bool HasExtensionInstalled { get; set; } | ||
| public int VaultItemCount { get; set; } | ||
| public int SharedItemCount { get; set; } | ||
| public bool HasRedeemedSponsorship { get; set; } | ||
| } |
264 changes: 264 additions & 0 deletions
264
src/Core/Dirt/Reports/ReportFeatures/SharedItemCountCalculator.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,264 @@ | ||
| using System.Runtime.InteropServices; | ||
| using Bit.Core.Dirt.Reports.Models.Data; | ||
|
|
||
| namespace Bit.Core.Dirt.Reports.ReportFeatures; | ||
|
|
||
| /// <summary> | ||
| /// Counts, per member, the distinct organization-owned ciphers reachable through the collections that | ||
| /// member can access. Both repository implementations share this so the two backends cannot diverge. | ||
| /// </summary> | ||
| public static class SharedItemCountCalculator | ||
| { | ||
| /// <summary> | ||
| /// Returns the distinct reachable cipher count keyed by organization user id. Members with no | ||
| /// reachable ciphers are omitted; callers should treat a missing key as zero. | ||
| /// </summary> | ||
| public static Dictionary<Guid, int> Calculate( | ||
| IReadOnlyCollection<MemberCollectionAccess> access, | ||
| IReadOnlyCollection<CollectionCipherLink> content) | ||
| { | ||
| if (access.Count == 0 || content.Count == 0) | ||
| { | ||
| return new Dictionary<Guid, int>(); | ||
| } | ||
|
|
||
| var links = AsSpan(content); | ||
|
|
||
| var collectionIndexes = new Dictionary<Guid, int>(); | ||
| var cipherIndexes = new Dictionary<Guid, int>(); | ||
| var linkCollections = new int[links.Length]; | ||
| var linkCiphers = new int[links.Length]; | ||
|
|
||
| for (var i = 0; i < links.Length; i++) | ||
| { | ||
| var link = links[i]; | ||
|
|
||
| if (!collectionIndexes.TryGetValue(link.CollectionId, out var collection)) | ||
| { | ||
| collection = collectionIndexes.Count; | ||
| collectionIndexes[link.CollectionId] = collection; | ||
| } | ||
|
|
||
| if (!cipherIndexes.TryGetValue(link.CipherId, out var cipher)) | ||
| { | ||
| cipher = cipherIndexes.Count; | ||
| cipherIndexes[link.CipherId] = cipher; | ||
| } | ||
|
|
||
| linkCollections[i] = collection; | ||
| linkCiphers[i] = cipher; | ||
| } | ||
|
|
||
| var collectionCount = collectionIndexes.Count; | ||
|
|
||
| // A cipher reachable through only one collection can never be reached twice by the same member, so | ||
| // those ciphers are counted in bulk per collection instead of being deduplicated one at a time. | ||
| // cipherSlots holds the link count per cipher first, then the cipher's slot in the stamp array, | ||
| // or -1 when the cipher needs no stamping. | ||
| var cipherSlots = new int[cipherIndexes.Count]; | ||
| for (var i = 0; i < linkCiphers.Length; i++) | ||
| { | ||
| cipherSlots[linkCiphers[i]]++; | ||
| } | ||
|
|
||
| var sharedCipherCount = 0; | ||
| for (var i = 0; i < cipherSlots.Length; i++) | ||
| { | ||
| cipherSlots[i] = cipherSlots[i] == 1 ? -1 : sharedCipherCount++; | ||
| } | ||
|
|
||
| var exclusiveCounts = new int[collectionCount]; | ||
| var sharedStarts = new int[collectionCount + 1]; | ||
| for (var i = 0; i < links.Length; i++) | ||
| { | ||
| if (cipherSlots[linkCiphers[i]] < 0) | ||
| { | ||
| exclusiveCounts[linkCollections[i]]++; | ||
| } | ||
| else | ||
| { | ||
| sharedStarts[linkCollections[i] + 1]++; | ||
| } | ||
| } | ||
|
|
||
| for (var i = 0; i < collectionCount; i++) | ||
| { | ||
| sharedStarts[i + 1] += sharedStarts[i]; | ||
| } | ||
|
|
||
| var sharedCiphers = new int[sharedStarts[collectionCount]]; | ||
| var sharedCursors = new int[collectionCount]; | ||
| Array.Copy(sharedStarts, sharedCursors, collectionCount); | ||
| for (var i = 0; i < links.Length; i++) | ||
| { | ||
| var slot = cipherSlots[linkCiphers[i]]; | ||
| if (slot >= 0) | ||
| { | ||
| sharedCiphers[sharedCursors[linkCollections[i]]++] = slot; | ||
| } | ||
| } | ||
|
|
||
| var edges = AsSpan(access); | ||
|
|
||
| var memberIndexes = new Dictionary<Guid, int>(); | ||
| var memberIds = new List<Guid>(); | ||
| var memberDegrees = new List<int>(); | ||
| for (var i = 0; i < edges.Length; i++) | ||
| { | ||
| var edge = edges[i]; | ||
| if (!collectionIndexes.ContainsKey(edge.CollectionId)) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| if (!memberIndexes.TryGetValue(edge.OrganizationUserId, out var member)) | ||
| { | ||
| member = memberIds.Count; | ||
| memberIndexes[edge.OrganizationUserId] = member; | ||
| memberIds.Add(edge.OrganizationUserId); | ||
| memberDegrees.Add(0); | ||
| } | ||
|
|
||
| memberDegrees[member]++; | ||
| } | ||
|
|
||
| var memberCount = memberIds.Count; | ||
| if (memberCount == 0) | ||
| { | ||
| return new Dictionary<Guid, int>(); | ||
| } | ||
|
|
||
| var memberStarts = new int[memberCount + 1]; | ||
| for (var i = 0; i < memberCount; i++) | ||
| { | ||
| memberStarts[i + 1] = memberStarts[i] + memberDegrees[i]; | ||
| } | ||
|
|
||
| var memberCollections = new int[memberStarts[memberCount]]; | ||
| var memberCursors = new int[memberCount]; | ||
| Array.Copy(memberStarts, memberCursors, memberCount); | ||
| for (var i = 0; i < edges.Length; i++) | ||
| { | ||
| var edge = edges[i]; | ||
| if (collectionIndexes.TryGetValue(edge.CollectionId, out var collection)) | ||
| { | ||
| memberCollections[memberCursors[memberIndexes[edge.OrganizationUserId]]++] = collection; | ||
| } | ||
| } | ||
|
|
||
| var stamps = new int[sharedCipherCount]; | ||
| var stampToken = 0; | ||
| var countsByAccessSet = new Dictionary<AccessSet, int>(new AccessSetComparer(memberCollections)); | ||
| var result = new Dictionary<Guid, int>(memberCount); | ||
|
|
||
| for (var member = 0; member < memberCount; member++) | ||
| { | ||
| var start = memberStarts[member]; | ||
| var length = memberStarts[member + 1] - start; | ||
|
|
||
| // Sorting and deduplicating in place turns the access set into a canonical signature, so members | ||
| // granted the same collections through different groups share one computation. | ||
| if (length > 1) | ||
| { | ||
| Array.Sort(memberCollections, start, length); | ||
|
|
||
| var write = start + 1; | ||
| for (var read = start + 1; read < start + length; read++) | ||
| { | ||
| if (memberCollections[read] != memberCollections[write - 1]) | ||
| { | ||
| memberCollections[write++] = memberCollections[read]; | ||
| } | ||
| } | ||
|
|
||
| length = write - start; | ||
| } | ||
|
|
||
| var accessSet = new AccessSet(start, length); | ||
| if (!countsByAccessSet.TryGetValue(accessSet, out var count)) | ||
| { | ||
| stampToken++; | ||
|
|
||
| for (var i = 0; i < length; i++) | ||
| { | ||
| var collection = memberCollections[start + i]; | ||
| count += exclusiveCounts[collection]; | ||
|
|
||
| var sharedEnd = sharedStarts[collection + 1]; | ||
| for (var shared = sharedStarts[collection]; shared < sharedEnd; shared++) | ||
| { | ||
| ref var stamp = ref stamps[sharedCiphers[shared]]; | ||
| if (stamp != stampToken) | ||
| { | ||
| stamp = stampToken; | ||
| count++; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| countsByAccessSet[accessSet] = count; | ||
| } | ||
|
|
||
| if (count > 0) | ||
| { | ||
| result[memberIds[member]] = count; | ||
| } | ||
| } | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Enumerating through the interface costs a dispatch per element, and these inputs run to millions of | ||
| /// edges, so the two shapes the repositories actually pass are read directly. | ||
| /// </summary> | ||
| private static ReadOnlySpan<T> AsSpan<T>(IReadOnlyCollection<T> source) | ||
| { | ||
| switch (source) | ||
| { | ||
| case T[] array: | ||
| return array; | ||
| case List<T> list: | ||
| return CollectionsMarshal.AsSpan(list); | ||
| default: | ||
| var copy = new T[source.Count]; | ||
| var index = 0; | ||
| foreach (var item in source) | ||
| { | ||
| copy[index++] = item; | ||
| } | ||
|
|
||
| return copy; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// A member's normalized access set, as a range within the shared collection-index buffer. | ||
| /// </summary> | ||
| private readonly record struct AccessSet(int Start, int Length); | ||
|
|
||
| private sealed class AccessSetComparer(int[] collections) : IEqualityComparer<AccessSet> | ||
| { | ||
| public bool Equals(AccessSet x, AccessSet y) | ||
| { | ||
| if (x.Length != y.Length) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| if (x.Start == y.Start) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| return collections.AsSpan(x.Start, x.Length).SequenceEqual(collections.AsSpan(y.Start, y.Length)); | ||
| } | ||
|
|
||
| public int GetHashCode(AccessSet accessSet) | ||
| { | ||
| var hash = new HashCode(); | ||
| hash.AddBytes(MemoryMarshal.AsBytes(collections.AsSpan(accessSet.Start, accessSet.Length))); | ||
| return hash.ToHashCode(); | ||
| } | ||
| } | ||
| } | ||
8 changes: 8 additions & 0 deletions
8
src/Core/Dirt/Repositories/IMemberAdoptionReportRepository.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| using Bit.Core.Dirt.Reports.Models.Data; | ||
|
|
||
| namespace Bit.Core.Dirt.Reports.Repositories; | ||
|
|
||
| public interface IMemberAdoptionReportRepository | ||
| { | ||
| Task<IReadOnlyList<MemberAdoptionReportDetail>> GetMemberAdoptionDetailsByOrganizationIdAsync(Guid organizationId); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
src/Infrastructure.Dapper/Dirt/MemberAdoptionReportRepository.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| using System.Data; | ||
| using Bit.Core.Dirt.Reports.Models.Data; | ||
| using Bit.Core.Dirt.Reports.ReportFeatures; | ||
| using Bit.Core.Dirt.Reports.Repositories; | ||
| using Bit.Core.Settings; | ||
| using Bit.Infrastructure.Dapper.Repositories; | ||
| using Dapper; | ||
| using Microsoft.Data.SqlClient; | ||
|
|
||
| namespace Bit.Infrastructure.Dapper.Dirt; | ||
|
|
||
| public class MemberAdoptionReportRepository : BaseRepository, IMemberAdoptionReportRepository | ||
| { | ||
| /// <summary> | ||
| /// Both reads stay well above the 30 second default. The detail read still runs per-member device and | ||
| /// cipher lookups for every confirmed member, and the access graph read returns one row per member to | ||
| /// collection edge plus one per collection to cipher edge, so a large organization spends real time | ||
| /// streaming rows even though the aggregation itself no longer happens in SQL. | ||
| /// </summary> | ||
| private const int ReportCommandTimeoutSeconds = 120; | ||
|
|
||
| public MemberAdoptionReportRepository(GlobalSettings globalSettings) | ||
| : this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString) | ||
| { | ||
| } | ||
|
|
||
| public MemberAdoptionReportRepository(string connectionString, string readOnlyConnectionString) | ||
| : base(connectionString, readOnlyConnectionString) | ||
| { | ||
| } | ||
|
|
||
| public async Task<IReadOnlyList<MemberAdoptionReportDetail>> GetMemberAdoptionDetailsByOrganizationIdAsync( | ||
| Guid organizationId) | ||
| { | ||
| await using var connection = new SqlConnection(ReadOnlyConnectionString); | ||
| var parameters = new { OrganizationId = organizationId }; | ||
|
|
||
| var details = (await connection.QueryAsync<MemberAdoptionReportDetail>( | ||
| "[dbo].[MemberAdoptionReport_ReadByOrganizationId]", | ||
| parameters, | ||
| commandType: CommandType.StoredProcedure, | ||
| commandTimeout: ReportCommandTimeoutSeconds)).AsList(); | ||
|
|
||
| using var accessGraph = await connection.QueryMultipleAsync( | ||
| "[dbo].[MemberAdoptionReport_ReadAccessGraphByOrganizationId]", | ||
| parameters, | ||
| commandType: CommandType.StoredProcedure, | ||
| commandTimeout: ReportCommandTimeoutSeconds); | ||
|
|
||
| var access = (await accessGraph.ReadAsync<MemberCollectionAccess>()).AsList(); | ||
| var content = (await accessGraph.ReadAsync<CollectionCipherLink>()).AsList(); | ||
|
|
||
| var sharedItemCounts = SharedItemCountCalculator.Calculate(access, content); | ||
|
|
||
| foreach (var detail in details) | ||
| { | ||
| detail.SharedItemCount = sharedItemCounts.GetValueOrDefault(detail.OrganizationUserId); | ||
| } | ||
|
|
||
| return details; | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.editorconfigrequires for*.cs, which fails theLintjob.Details and fix
.editorconfigsetscharset = utf-8-bomfor[*.{cs,csx,vb,vbx}], anddotnet formatenforces it. Three of the new.csfiles in this PR are plain ASCII with no BOM, while every other new file in the same diff (MemberAdoptionReportDetail.cs, both repositories, the test files) has one:src/Core/Dirt/Models/Data/MemberAdoptionReportAccessGraph.cssrc/Core/Dirt/Reports/ReportFeatures/SharedItemCountCalculator.cstest/Core.Test/Dirt/ReportFeatures/SharedItemCountCalculatorTests.csLint(dotnet format --verify-no-changes) is currently failing on this PR and passing on the base PR, andbuild-artifactsplusbuild-mssqlmigratorutilityboth declareneeds: lint, so the Docker image and migrator builds are skipped.Running
dotnet formatover the three projects will rewrite them with the BOM. I have not seen the job log, so I can only say these three definitely violate the configured charset — there may be another fixer firing as well, which the same command would also settle.Reference:
.editorconfig:17-19,.github/workflows/build.yml:36-37