1
0
mirror of https://github.com/bitwarden/server synced 2026-02-22 04:13:43 +00:00

Merge remote-tracking branch 'origin/main' into ac/pm-23768/server-public-api---add-restore/revoke-for-members

This commit is contained in:
Thomas Rittson
2026-01-16 14:40:29 +10:00
26 changed files with 911 additions and 250 deletions

View File

@@ -0,0 +1,53 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Collections;
public static class CollectionUtils
{
/// <summary>
/// Arranges Collection and CollectionUser objects to create default user collections.
/// </summary>
/// <param name="organizationId">The organization ID.</param>
/// <param name="organizationUserIds">The IDs for organization users who need default collections.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns>A tuple containing the collections and collection users.</returns>
public static (ICollection<Collection> collections, ICollection<CollectionUser> collectionUsers)
BuildDefaultUserCollections(Guid organizationId, IEnumerable<Guid> organizationUserIds,
string defaultCollectionName)
{
var now = DateTime.UtcNow;
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();
foreach (var orgUserId in organizationUserIds)
{
var collectionId = CoreHelpers.GenerateComb();
collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = now,
RevisionDate = now,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null
});
collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}
return (collections, collectionUsers);
}
}

View File

@@ -4,9 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.OrganizationConfirmation;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -83,19 +81,10 @@ public class AutomaticallyConfirmOrganizationUserCommand(IOrganizationUserReposi
return;
}
await collectionRepository.CreateAsync(
new Collection
{
OrganizationId = request.Organization!.Id,
Name = request.DefaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
},
groups: null,
[new CollectionAccessSelection
{
Id = request.OrganizationUser!.Id,
Manage = true
}]);
await collectionRepository.CreateDefaultCollectionsAsync(
request.Organization!.Id,
[request.OrganizationUser!.Id],
request.DefaultUserCollectionName);
}
catch (Exception ex)
{

View File

@@ -14,7 +14,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
@@ -294,21 +293,10 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
var defaultCollection = new Collection
{
OrganizationId = organizationUser.OrganizationId,
Name = defaultUserCollectionName,
Type = CollectionType.DefaultUserCollection
};
var collectionUser = new CollectionAccessSelection
{
Id = organizationUser.Id,
ReadOnly = false,
HidePasswords = false,
Manage = true
};
await _collectionRepository.CreateAsync(defaultCollection, groups: null, users: [collectionUser]);
await _collectionRepository.CreateDefaultCollectionsAsync(
organizationUser.OrganizationId,
[organizationUser.Id],
defaultUserCollectionName);
}
/// <summary>
@@ -339,7 +327,7 @@ public class ConfirmOrganizationUserCommand : IConfirmOrganizationUserCommand
return;
}
await _collectionRepository.UpsertDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
await _collectionRepository.CreateDefaultCollectionsAsync(organizationId, eligibleOrganizationUserIds, defaultUserCollectionName);
}
/// <summary>

View File

@@ -57,14 +57,15 @@ public class OrganizationDataOwnershipPolicyValidator(
var userOrgIds = requirements
.Select(requirement => requirement.GetDefaultCollectionRequestOnPolicyEnable(policyUpdate.OrganizationId))
.Where(request => request.ShouldCreateDefaultCollection)
.Select(request => request.OrganizationUserId);
.Select(request => request.OrganizationUserId)
.ToList();
if (!userOrgIds.Any())
{
return;
}
await collectionRepository.UpsertDefaultCollectionsAsync(
await collectionRepository.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
userOrgIds,
defaultCollectionName);

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using System.Globalization;
using System.Text.RegularExpressions;
using Stripe;
namespace Bit.Core.Billing.Extensions;
@@ -51,7 +52,7 @@ public static class InvoiceExtensions
if (string.IsNullOrEmpty(priceInfo) && line.Quantity > 0)
{
var pricePerItem = (line.Amount / 100m) / line.Quantity;
priceInfo = $"(at ${pricePerItem:F2} / month)";
priceInfo = string.Format(CultureInfo.InvariantCulture, "(at ${0:F2} / month)", pricePerItem);
}
var taxDescription = $"{line.Quantity} × Tax {priceInfo}";
@@ -70,7 +71,7 @@ public static class InvoiceExtensions
if (tax > 0)
{
var taxAmount = tax / 100m;
items.Add($"1 × Tax (at ${taxAmount:F2} / month)");
items.Add(string.Format(CultureInfo.InvariantCulture, "1 × Tax (at ${0:F2} / month)", taxAmount));
}
return items;

View File

@@ -164,6 +164,7 @@ public static class FeatureFlagKeys
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
public const string SSHAgent = "ssh-agent";

View File

@@ -64,11 +64,22 @@ public interface ICollectionRepository : IRepository<Collection, Guid>
IEnumerable<CollectionAccessSelection> users, IEnumerable<CollectionAccessSelection> groups);
/// <summary>
/// Creates default user collections for the specified organization users if they do not already have one.
/// Creates default user collections for the specified organization users.
/// Filters internally to only create collections for users who don't already have one.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
/// <returns></returns>
Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
/// <summary>
/// Creates default user collections for the specified organization users using bulk insert operations.
/// Use this if you need to create collections for > ~1k users.
/// Filters internally to only create collections for users who don't already have one.
/// </summary>
/// <param name="organizationId">The Organization ID.</param>
/// <param name="organizationUserIds">The Organization User IDs to create default collections for.</param>
/// <param name="defaultCollectionName">The encrypted string to use as the default collection name.</param>
Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName);
}

View File

@@ -160,6 +160,21 @@ public static class DapperHelpers
return ids.ToArrayTVP("GuidId");
}
public static DataTable ToTwoGuidIdArrayTVP(this IEnumerable<(Guid id1, Guid id2)> values)
{
var table = new DataTable();
table.SetTypeName("[dbo].[TwoGuidIdArray]");
table.Columns.Add("Id1", typeof(Guid));
table.Columns.Add("Id2", typeof(Guid));
foreach (var value in values)
{
table.Rows.Add(value.id1, value.id2);
}
return table;
}
public static DataTable ToArrayTVP<T>(this IEnumerable<T> values, string columnName)
{
var table = new DataTable();

View File

@@ -1,6 +1,7 @@
using System.Data;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@@ -360,7 +361,45 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
}
}
public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
{
return;
}
var organizationUserCollectionIds = organizationUserIds
.Select(ou => (ou, CoreHelpers.GenerateComb()))
.ToTwoGuidIdArrayTVP();
await using var connection = new SqlConnection(ConnectionString);
await connection.OpenAsync();
await using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
"[dbo].[Collection_CreateDefaultCollections]",
new
{
OrganizationId = organizationId,
DefaultCollectionName = defaultCollectionName,
OrganizationUserCollectionIds = organizationUserCollectionIds
},
commandType: CommandType.StoredProcedure,
transaction: transaction);
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
@@ -377,7 +416,8 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);
var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
var (collections, collectionUsers) =
CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
if (!collectionUsers.Any() || !collections.Any())
{
@@ -387,11 +427,11 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
await BulkResourceCreationService.CreateCollectionsAsync(connection, transaction, collections);
await BulkResourceCreationService.CreateCollectionsUsersAsync(connection, transaction, collectionUsers);
transaction.Commit();
await transaction.CommitAsync();
}
catch
{
transaction.Rollback();
await transaction.RollbackAsync();
throw;
}
}
@@ -421,40 +461,6 @@ public class CollectionRepository : Repository<Collection, Guid>, ICollectionRep
return organizationUserIds.ToHashSet();
}
private (List<CollectionUser> collectionUser, List<Collection> collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable<Guid> missingDefaultCollectionUserIds, string defaultCollectionName)
{
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();
foreach (var orgUserId in missingDefaultCollectionUserIds)
{
var collectionId = CoreHelpers.GenerateComb();
collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null
});
collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}
return (collectionUsers, collections);
}
public class CollectionWithGroupsAndUsers : Collection
{
public CollectionWithGroupsAndUsers() { }

View File

@@ -1,11 +1,10 @@
using AutoMapper;
using Bit.Core.AdminConsole.OrganizationFeatures.Collections;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.Infrastructure.EntityFramework.Repositories.Queries;
using LinqToDB.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -794,7 +793,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
// SaveChangesAsync is expected to be called outside this method
}
public async Task UpsertDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
public async Task CreateDefaultCollectionsAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds, string defaultCollectionName)
{
organizationUserIds = organizationUserIds.ToList();
if (!organizationUserIds.Any())
@@ -808,15 +807,15 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
var orgUserIdWithDefaultCollection = await GetOrgUserIdsWithDefaultCollectionAsync(dbContext, organizationId);
var missingDefaultCollectionUserIds = organizationUserIds.Except(orgUserIdWithDefaultCollection);
var (collectionUsers, collections) = BuildDefaultCollectionForUsers(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
var (collections, collectionUsers) = CollectionUtils.BuildDefaultUserCollections(organizationId, missingDefaultCollectionUserIds, defaultCollectionName);
if (!collectionUsers.Any() || !collections.Any())
if (!collections.Any() || !collectionUsers.Any())
{
return;
}
await dbContext.BulkCopyAsync(collections);
await dbContext.BulkCopyAsync(collectionUsers);
await dbContext.Collections.AddRangeAsync(Mapper.Map<IEnumerable<Collection>>(collections));
await dbContext.CollectionUsers.AddRangeAsync(Mapper.Map<IEnumerable<CollectionUser>>(collectionUsers));
await dbContext.SaveChangesAsync();
}
@@ -844,37 +843,7 @@ public class CollectionRepository : Repository<Core.Entities.Collection, Collect
return results.ToHashSet();
}
private (List<CollectionUser> collectionUser, List<Collection> collection) BuildDefaultCollectionForUsers(Guid organizationId, IEnumerable<Guid> missingDefaultCollectionUserIds, string defaultCollectionName)
{
var collectionUsers = new List<CollectionUser>();
var collections = new List<Collection>();
foreach (var orgUserId in missingDefaultCollectionUserIds)
{
var collectionId = CoreHelpers.GenerateComb();
collections.Add(new Collection
{
Id = collectionId,
OrganizationId = organizationId,
Name = defaultCollectionName,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
Type = CollectionType.DefaultUserCollection,
DefaultUserCollectionEmail = null
});
collectionUsers.Add(new CollectionUser
{
CollectionId = collectionId,
OrganizationUserId = orgUserId,
ReadOnly = false,
HidePasswords = false,
Manage = true,
});
}
return (collectionUsers, collections);
}
public Task CreateDefaultCollectionsBulkAsync(Guid organizationId, IEnumerable<Guid> organizationUserIds,
string defaultCollectionName) =>
CreateDefaultCollectionsAsync(organizationId, organizationUserIds, defaultCollectionName);
}

View File

@@ -17,8 +17,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using DP = Microsoft.AspNetCore.DataProtection;
#nullable enable
namespace Bit.Infrastructure.EntityFramework.Repositories;
public class DatabaseContext : DbContext

View File

@@ -0,0 +1,69 @@
-- Creates default user collections for organization users
-- Filters out existing default collections at database level
CREATE PROCEDURE [dbo].[Collection_CreateDefaultCollections]
@OrganizationId UNIQUEIDENTIFIER,
@DefaultCollectionName VARCHAR(MAX),
@OrganizationUserCollectionIds AS [dbo].[TwoGuidIdArray] READONLY -- OrganizationUserId, CollectionId
AS
BEGIN
SET NOCOUNT ON
DECLARE @Now DATETIME2(7) = GETUTCDATE()
-- Filter to only users who don't have default collections
SELECT ids.Id1, ids.Id2
INTO #FilteredIds
FROM @OrganizationUserCollectionIds ids
WHERE NOT EXISTS (
SELECT 1
FROM [dbo].[CollectionUser] cu
INNER JOIN [dbo].[Collection] c ON c.Id = cu.CollectionId
WHERE c.OrganizationId = @OrganizationId
AND c.[Type] = 1 -- CollectionType.DefaultUserCollection
AND cu.OrganizationUserId = ids.Id1
);
-- Insert collections only for users who don't have default collections yet
INSERT INTO [dbo].[Collection]
(
[Id],
[OrganizationId],
[Name],
[CreationDate],
[RevisionDate],
[Type],
[ExternalId],
[DefaultUserCollectionEmail]
)
SELECT
ids.Id2, -- CollectionId
@OrganizationId,
@DefaultCollectionName,
@Now,
@Now,
1, -- CollectionType.DefaultUserCollection
NULL,
NULL
FROM
#FilteredIds ids;
-- Insert collection user mappings
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
ids.Id2, -- CollectionId
ids.Id1, -- OrganizationUserId
0, -- ReadOnly = false
0, -- HidePasswords = false
1 -- Manage = true
FROM
#FilteredIds ids;
DROP TABLE #FilteredIds;
END