Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Core/Dirt/Models/Data/MemberAdoptionReportAccessGraph.cs
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);
14 changes: 14 additions & 0 deletions src/Core/Dirt/Models/Data/MemberAdoptionReportDetail.cs
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 src/Core/Dirt/Reports/ReportFeatures/SharedItemCountCalculator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
using System.Runtime.InteropServices;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: This file and two others are missing the UTF-8 BOM that .editorconfig requires for *.cs, which fails the Lint job.

Details and fix

.editorconfig sets charset = utf-8-bom for [*.{cs,csx,vb,vbx}], and dotnet format enforces it. Three of the new .cs files 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.cs
  • src/Core/Dirt/Reports/ReportFeatures/SharedItemCountCalculator.cs
  • test/Core.Test/Dirt/ReportFeatures/SharedItemCountCalculatorTests.cs

Lint (dotnet format --verify-no-changes) is currently failing on this PR and passing on the base PR, and build-artifacts plus build-mssqlmigratorutility both declare needs: lint, so the Docker image and migrator builds are skipped.

Running dotnet format over 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

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();
}
}
}
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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public static void AddDapperRepositories(this IServiceCollection services, bool
services.AddSingleton<IOrganizationApplicationRepository, OrganizationApplicationRepository>();
services.AddSingleton<IOrganizationDeleteTaskRepository, OrganizationDeleteTaskRepository>();
services.AddSingleton<IOrganizationMemberBaseDetailRepository, OrganizationMemberBaseDetailRepository>();
services.AddSingleton<IMemberAdoptionReportRepository, MemberAdoptionReportRepository>();

if (selfHosted)
{
Expand Down
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;
}
}
Loading