1
0
mirror of https://github.com/bitwarden/server synced 2026-01-28 15:23:38 +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

@@ -140,10 +140,13 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -372,6 +375,10 @@ Global
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -432,6 +439,7 @@ Global
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -5,12 +5,19 @@
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
This script validates migration files in util/Migrator/DbScripts/ to ensure:
This script validates migration files to ensure:
For SQL migrations in util/Migrator/DbScripts/:
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
2. New migrations are chronologically ordered (filename sorts after existing migrations)
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
4. A 2-digit sequence number is included (e.g., _00, _01)
For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations:
1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs
2. Each migration has both .cs and .Designer.cs files
3. New migrations are chronologically ordered (timestamp sorts after existing migrations)
.PARAMETER BaseRef
The base git reference to compare against (e.g., 'main', 'HEAD~1')
@@ -58,75 +65,288 @@ $currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/"
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
$sqlValidationFailed = $false
if ($addedMigrations.Count -eq 0) {
Write-Host "No new migration files added."
exit 0
Write-Host "No new SQL migration files added."
Write-Host ""
}
else {
Write-Host "New SQL migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous SQL migrations found (initial commit?). Skipping chronological validation."
Write-Host ""
}
else {
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last SQL migration in base reference: $lastBaseMigration"
Write-Host ""
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$sqlValidationFailed = $true
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$sqlValidationFailed = $true
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
}
}
Write-Host ""
}
if ($sqlValidationFailed) {
Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new SQL migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
}
else {
Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order"
}
Write-Host ""
}
Write-Host "New migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
# ===========================================================================================
# Validate Entity Framework Migrations
# ===========================================================================================
Write-Host "==================================================================="
Write-Host "Validating Entity Framework Migrations"
Write-Host "==================================================================="
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous migrations found (initial commit?). Skipping validation."
exit 0
}
$efMigrationPaths = @(
@{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" },
@{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" },
@{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" }
)
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last migration in base reference: $lastBaseMigration"
Write-Host ""
$efValidationFailed = $false
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
foreach ($migrationPathInfo in $efMigrationPaths) {
$efPath = $migrationPathInfo.Path
$dbName = $migrationPathInfo.Name
$validationFailed = $false
Write-Host "-------------------------------------------------------------------"
Write-Host "Checking $dbName EF migrations in $efPath"
Write-Host "-------------------------------------------------------------------"
Write-Host ""
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
}
catch {
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$validationFailed = $true
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
if ($addedMigrations.Count -eq 0) {
Write-Host "No new $dbName EF migration files added."
Write-Host ""
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$validationFailed = $true
Write-Host "New $dbName EF migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous $dbName migrations found. Skipping chronological validation."
Write-Host ""
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last $dbName migration in base reference: $lastBaseMigration"
Write-Host ""
}
# Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs
$efFormatRegex = '^[0-9]{14}_.+\.cs$'
# Group migrations by base name (without .Designer.cs suffix)
$migrationGroups = @{}
$unmatchedFiles = @()
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Extract base name (remove .Designer.cs or .cs)
if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') {
$baseName = $matches[1]
if (-not $migrationGroups.ContainsKey($baseName)) {
$migrationGroups[$baseName] = @()
}
$migrationGroups[$baseName] += $migrationName
}
else {
# Track files that don't match the expected pattern
$unmatchedFiles += $migrationName
}
}
# Flag any files that don't match the expected pattern
if ($unmatchedFiles.Count -gt 0) {
Write-Host "ERROR: The following migration files do not match the required format:"
foreach ($unmatchedFile in $unmatchedFiles) {
Write-Host " - $unmatchedFile"
}
Write-Host ""
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs"
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
Write-Host " - Description: Descriptive name using PascalCase"
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
Write-Host ""
$efValidationFailed = $true
}
foreach ($baseName in $migrationGroups.Keys | Sort-Object) {
$files = $migrationGroups[$baseName]
# Validate format
$mainFile = "$baseName.cs"
$designerFile = "$baseName.Designer.cs"
if ($mainFile -notmatch $efFormatRegex) {
Write-Host "ERROR: Migration '$mainFile' does not match required format"
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
Write-Host "Example: 20250115120000_AddNewFeature.cs"
$efValidationFailed = $true
continue
}
# Check that both .cs and .Designer.cs files exist
$hasCsFile = $files -contains $mainFile
$hasDesignerFile = $files -contains $designerFile
if (-not $hasCsFile) {
Write-Host "ERROR: Missing main migration file: $mainFile"
$efValidationFailed = $true
}
if (-not $hasDesignerFile) {
Write-Host "ERROR: Missing designer file: $designerFile"
Write-Host "Each EF migration must have both a .cs and .Designer.cs file"
$efValidationFailed = $true
}
if (-not $hasCsFile -or -not $hasDesignerFile) {
continue
}
# Compare migration timestamp with last base migration (using ordinal string comparison)
if ($baseMigrations.Count -gt 0) {
if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'"
$efValidationFailed = $true
}
else {
Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'"
}
}
else {
Write-Host "OK: '$mainFile' (no previous migrations to compare)"
}
}
Write-Host ""
}
if ($efValidationFailed) {
Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new EF migration files must:"
Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " 2. Include both .cs and .Designer.cs files"
Write-Host " 3. Have a timestamp that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in the respective Migrations directory"
Write-Host " 2. Ensure both .cs and .Designer.cs files exist"
Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " 4. Ensure the timestamp is after the last migration"
Write-Host ""
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
}
else {
Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order"
}
Write-Host ""
Write-Host "==================================================================="
Write-Host "Validation Summary"
Write-Host "==================================================================="
if ($sqlValidationFailed -or $efValidationFailed) {
if ($sqlValidationFailed) {
Write-Host "❌ SQL migrations validation FAILED"
}
else {
Write-Host "✓ SQL migrations validation PASSED"
}
if ($efValidationFailed) {
Write-Host "❌ EF migrations validation FAILED"
}
else {
Write-Host "✓ EF migrations validation PASSED"
}
if ($validationFailed) {
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
Write-Host "OVERALL RESULT: FAILED"
exit 1
}
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
exit 0
else {
Write-Host "✓ SQL migrations validation PASSED"
Write-Host "✓ EF migrations validation PASSED"
Write-Host ""
Write-Host "OVERALL RESULT: SUCCESS"
exit 0
}

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

View File

@@ -10,7 +10,6 @@ using Bit.Core.AdminConsole.Utilities.v2;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
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;
@@ -204,14 +203,10 @@ public class AutomaticallyConfirmUsersCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.CreateAsync(
Arg.Is<Collection>(c =>
c.OrganizationId == organization.Id &&
c.Name == defaultCollectionName &&
c.Type == CollectionType.DefaultUserCollection),
Arg.Is<IEnumerable<CollectionAccessSelection>>(groups => groups == null),
Arg.Is<IEnumerable<CollectionAccessSelection>>(access =>
access.FirstOrDefault(x => x.Id == organizationUser.Id && x.Manage) != null));
.CreateDefaultCollectionsAsync(
organization.Id,
Arg.Is<IEnumerable<Guid>>(ids => ids.Single() == organizationUser.Id),
defaultCollectionName);
}
[Theory]
@@ -253,9 +248,7 @@ public class AutomaticallyConfirmUsersCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.CreateAsync(Arg.Any<Collection>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>());
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[Theory]
@@ -291,9 +284,7 @@ public class AutomaticallyConfirmUsersCommandTests
var collectionException = new Exception("Collection creation failed");
sutProvider.GetDependency<ICollectionRepository>()
.CreateAsync(Arg.Any<Collection>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Any<IEnumerable<CollectionAccessSelection>>())
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>())
.ThrowsAsync(collectionException);
// Act

View File

@@ -13,7 +13,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.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
@@ -493,15 +492,10 @@ public class ConfirmOrganizationUserCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.Received(1)
.CreateAsync(
Arg.Is<Collection>(c =>
c.Name == collectionName &&
c.OrganizationId == organization.Id &&
c.Type == CollectionType.DefaultUserCollection),
Arg.Any<IEnumerable<CollectionAccessSelection>>(),
Arg.Is<IEnumerable<CollectionAccessSelection>>(cu =>
cu.Single().Id == orgUser.Id &&
cu.Single().Manage));
.CreateDefaultCollectionsAsync(
organization.Id,
Arg.Is<IEnumerable<Guid>>(ids => ids.Single() == orgUser.Id),
collectionName);
}
[Theory, BitAutoData]
@@ -522,7 +516,7 @@ public class ConfirmOrganizationUserCommandTests
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[Theory, BitAutoData]
@@ -539,24 +533,15 @@ public class ConfirmOrganizationUserCommandTests
sutProvider.GetDependency<IOrganizationUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { orgUser });
sutProvider.GetDependency<IUserRepository>().GetManyAsync(default).ReturnsForAnyArgs(new[] { user });
var policyDetails = new PolicyDetails
{
OrganizationId = org.Id,
OrganizationUserId = orgUser.Id,
IsProvider = false,
OrganizationUserStatus = orgUser.Status,
OrganizationUserType = orgUser.Type,
PolicyType = PolicyType.OrganizationDataOwnership
};
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(orgUser.UserId!.Value)
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, [policyDetails]));
.Returns(new OrganizationDataOwnershipPolicyRequirement(OrganizationDataOwnershipState.Disabled, []));
await sutProvider.Sut.ConfirmUserAsync(orgUser.OrganizationId, orgUser.Id, key, confirmingUser.Id, collectionName);
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
.CreateDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[Theory, BitAutoData]

View File

@@ -38,7 +38,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[Theory, BitAutoData]
@@ -60,7 +60,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
[Theory, BitAutoData]
@@ -86,7 +86,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await collectionRepository
.DidNotReceive()
.UpsertDefaultCollectionsAsync(
.CreateDefaultCollectionsBulkAsync(
Arg.Any<Guid>(),
Arg.Any<IEnumerable<Guid>>(),
Arg.Any<string>());
@@ -172,10 +172,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Act
await sut.ExecuteSideEffectsAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
// Assert - Should call with all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
.UpsertDefaultCollectionsAsync(
.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
_defaultUserCollectionName);
@@ -210,7 +210,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceive()
.UpsertDefaultCollectionsAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
.CreateDefaultCollectionsBulkAsync(Arg.Any<Guid>(), Arg.Any<IEnumerable<Guid>>(), Arg.Any<string>());
}
private static IPolicyRepository ArrangePolicyRepository(IEnumerable<OrganizationPolicyDetails> policyDetails)
@@ -251,7 +251,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
.CreateDefaultCollectionsBulkAsync(default, default, default);
}
[Theory, BitAutoData]
@@ -273,7 +273,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
.CreateDefaultCollectionsBulkAsync(default, default, default);
}
[Theory, BitAutoData]
@@ -299,7 +299,7 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await collectionRepository
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(
.CreateDefaultCollectionsBulkAsync(
default,
default,
default);
@@ -336,10 +336,10 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Act
await sut.ExecutePostUpsertSideEffectAsync(policyRequest, postUpdatedPolicy, previousPolicyState);
// Assert
// Assert - Should call with all user IDs (repository does internal filtering)
await collectionRepository
.Received(1)
.UpsertDefaultCollectionsAsync(
.CreateDefaultCollectionsBulkAsync(
policyUpdate.OrganizationId,
Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 3),
_defaultUserCollectionName);
@@ -367,6 +367,6 @@ public class OrganizationDataOwnershipPolicyValidatorTests
// Assert
await sutProvider.GetDependency<ICollectionRepository>()
.DidNotReceiveWithAnyArgs()
.UpsertDefaultCollectionsAsync(default, default, default);
.CreateDefaultCollectionsBulkAsync(default, default, default);
}
}

View File

@@ -0,0 +1,53 @@
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class CreateDefaultCollectionsBulkAsyncTests
{
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsBulkAsync_CreatesDefaultCollections_Success(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success(
collectionRepository.CreateDefaultCollectionsBulkAsync,
organizationRepository,
userRepository,
organizationUserRepository,
collectionRepository);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsBulkAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers(
collectionRepository.CreateDefaultCollectionsBulkAsync,
organizationRepository,
userRepository,
organizationUserRepository,
collectionRepository);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsBulkAsync_IgnoresAllExistingUsers(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers(
collectionRepository.CreateDefaultCollectionsBulkAsync,
organizationRepository,
userRepository,
organizationUserRepository,
collectionRepository);
}
}

View File

@@ -6,10 +6,14 @@ using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class UpsertDefaultCollectionsTests
/// <summary>
/// Shared tests for CreateDefaultCollections methods - both bulk and non-bulk implementations,
/// as they share the same behavior. Both test suites call the tests in this class.
/// </summary>
public static class CreateDefaultCollectionsSharedTests
{
[Theory, DatabaseData]
public async Task UpsertDefaultCollectionsAsync_ShouldCreateDefaultCollection_WhenUsersDoNotHaveDefaultCollection(
public static async Task CreatesDefaultCollections_Success(
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -21,14 +25,13 @@ public class UpsertDefaultCollectionsTests
var resultOrganizationUsers = await Task.WhenAll(
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization),
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
);
);
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id);
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
var defaultCollectionName = $"default-name-{organization.Id}";
// Act
await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
@@ -36,8 +39,8 @@ public class UpsertDefaultCollectionsTests
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
}
[Theory, DatabaseData]
public async Task UpsertDefaultCollectionsAsync_ShouldUpsertCreateDefaultCollection_ForUsersWithAndWithoutDefaultCollectionsExist(
public static async Task CreatesForNewUsersOnly_AndIgnoresExistingUsers(
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -51,31 +54,30 @@ public class UpsertDefaultCollectionsTests
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
);
var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id);
var arrangedOrgUserIds = arrangedOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
var defaultCollectionName = $"default-name-{organization.Id}";
await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers);
await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, arrangedOrgUserIds, defaultCollectionName, arrangedOrganizationUsers);
var newOrganizationUsers = new List<OrganizationUser>()
var newOrganizationUsers = new List<OrganizationUser>
{
await CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
};
var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers);
var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id);
var affectedOrgUsers = newOrganizationUsers.Concat(arrangedOrganizationUsers).ToList();
var affectedOrgUserIds = affectedOrgUsers.Select(organizationUser => organizationUser.Id).ToList();
// Act
await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, arrangedOrganizationUsers, organization.Id);
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, affectedOrgUsers, organization.Id);
await CleanupAsync(organizationRepository, userRepository, organization, affectedOrgUsers);
}
[Theory, DatabaseData]
public async Task UpsertDefaultCollectionsAsync_ShouldNotCreateDefaultCollection_WhenUsersAlreadyHaveOne(
public static async Task IgnoresAllExistingUsers(
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -89,26 +91,29 @@ public class UpsertDefaultCollectionsTests
CreateUserForOrgAsync(userRepository, organizationUserRepository, organization)
);
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id);
var affectedOrgUserIds = resultOrganizationUsers.Select(organizationUser => organizationUser.Id).ToList();
var defaultCollectionName = $"default-name-{organization.Id}";
await CreateUsersWithExistingDefaultCollectionsAsync(createDefaultCollectionsFunc, collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
await CreateUsersWithExistingDefaultCollectionsAsync(collectionRepository, organization.Id, affectedOrgUserIds, defaultCollectionName, resultOrganizationUsers);
// Act - Try to create again, should silently filter and not create duplicates
await createDefaultCollectionsFunc(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Act
await collectionRepository.UpsertDefaultCollectionsAsync(organization.Id, affectedOrgUserIds, defaultCollectionName);
// Assert
// Assert - Original collections should remain unchanged, still only one per user
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organization.Id);
await CleanupAsync(organizationRepository, userRepository, organization, resultOrganizationUsers);
}
private static async Task CreateUsersWithExistingDefaultCollectionsAsync(ICollectionRepository collectionRepository,
Guid organizationId, IEnumerable<Guid> affectedOrgUserIds, string defaultCollectionName,
private static async Task CreateUsersWithExistingDefaultCollectionsAsync(
Func<Guid, IEnumerable<Guid>, string, Task> createDefaultCollectionsFunc,
ICollectionRepository collectionRepository,
Guid organizationId,
IEnumerable<Guid> affectedOrgUserIds,
string defaultCollectionName,
OrganizationUser[] resultOrganizationUsers)
{
await collectionRepository.UpsertDefaultCollectionsAsync(organizationId, affectedOrgUserIds, defaultCollectionName);
await createDefaultCollectionsFunc(organizationId, affectedOrgUserIds, defaultCollectionName);
await AssertAllUsersHaveOneDefaultCollectionAsync(collectionRepository, resultOrganizationUsers, organizationId);
}
@@ -131,7 +136,6 @@ public class UpsertDefaultCollectionsTests
private static async Task<OrganizationUser> CreateUserForOrgAsync(IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository, Organization organization)
{
var user = await userRepository.CreateTestUserAsync();
var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user);

View File

@@ -0,0 +1,52 @@
using Bit.Core.Repositories;
using Xunit;
namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.CollectionRepository;
public class CreateDefaultCollectionsAsyncTests
{
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsAsync_CreatesDefaultCollections_Success(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
await CreateDefaultCollectionsSharedTests.CreatesDefaultCollections_Success(
collectionRepository.CreateDefaultCollectionsAsync,
organizationRepository,
userRepository,
organizationUserRepository,
collectionRepository);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsAsync_CreatesForNewUsersOnly_AndIgnoresExistingUsers(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
await CreateDefaultCollectionsSharedTests.CreatesForNewUsersOnly_AndIgnoresExistingUsers(
collectionRepository.CreateDefaultCollectionsAsync,
organizationRepository,
userRepository,
organizationUserRepository,
collectionRepository);
}
[Theory, DatabaseData]
public async Task CreateDefaultCollectionsAsync_IgnoresAllExistingUsers(
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IOrganizationUserRepository organizationUserRepository,
ICollectionRepository collectionRepository)
{
await CreateDefaultCollectionsSharedTests.IgnoresAllExistingUsers(
collectionRepository.CreateDefaultCollectionsAsync,
organizationRepository,
userRepository,
organizationUserRepository,
collectionRepository);
}
}

View File

@@ -0,0 +1 @@
[assembly: CaptureTrace]

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.0" />
<PackageReference Include="xunit.v3" Version="3.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\util\Server\Server.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
namespace Bit.Server.IntegrationTest;
public class Server : WebApplicationFactory<Program>
{
public string? ContentRoot { get; set; }
public string? WebRoot { get; set; }
public bool ServeUnknown { get; set; }
public bool? WebVault { get; set; }
public string? AppIdLocation { get; set; }
protected override IWebHostBuilder? CreateWebHostBuilder()
{
var args = new List<string>
{
"/contentRoot",
ContentRoot ?? "",
"/webRoot",
WebRoot ?? "",
"/serveUnknown",
ServeUnknown.ToString().ToLowerInvariant(),
};
if (WebVault.HasValue)
{
args.Add("/webVault");
args.Add(WebVault.Value.ToString().ToLowerInvariant());
}
if (!string.IsNullOrEmpty(AppIdLocation))
{
args.Add("/appIdLocation");
args.Add(AppIdLocation);
}
var builder = WebHostBuilderFactory.CreateFromTypesAssemblyEntryPoint<Program>([.. args])
?? throw new InvalidProgramException("Could not create builder from assembly.");
builder.UseSetting("TEST_CONTENTROOT_SERVER", ContentRoot);
return builder;
}
}

View File

@@ -0,0 +1,102 @@
using System.Net;
using System.Runtime.CompilerServices;
namespace Bit.Server.IntegrationTest;
public class ServerTests
{
[Fact]
public async Task AttachmentsStyleUse()
{
using var tempDir = new TempDir();
await tempDir.WriteAsync("my-file.txt", "Hello!");
using var server = new Server
{
ContentRoot = tempDir.Info.FullName,
WebRoot = ".",
ServeUnknown = true,
};
var client = server.CreateClient();
var response = await client.GetAsync("/my-file.txt", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("Hello!", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
[Fact]
public async Task WebVaultStyleUse()
{
using var tempDir = new TempDir();
await tempDir.WriteAsync("index.html", "<html></html>");
await tempDir.WriteAsync(Path.Join("app", "file.js"), "AppStuff");
await tempDir.WriteAsync(Path.Join("locales", "file.json"), "LocalesStuff");
await tempDir.WriteAsync(Path.Join("fonts", "file.ttf"), "FontsStuff");
await tempDir.WriteAsync(Path.Join("connectors", "file.js"), "ConnectorsStuff");
await tempDir.WriteAsync(Path.Join("scripts", "file.js"), "ScriptsStuff");
await tempDir.WriteAsync(Path.Join("images", "file.avif"), "ImagesStuff");
await tempDir.WriteAsync(Path.Join("test", "file.json"), "{}");
using var server = new Server
{
ContentRoot = tempDir.Info.FullName,
WebRoot = ".",
ServeUnknown = false,
WebVault = true,
AppIdLocation = Path.Join(tempDir.Info.FullName, "test", "file.json"),
};
var client = server.CreateClient();
// Going to root should return the default file
var response = await client.GetAsync("", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("<html></html>", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
// No caching on the default document
Assert.Null(response.Headers.CacheControl?.MaxAge);
await ExpectMaxAgeAsync("app/file.js", TimeSpan.FromDays(14));
await ExpectMaxAgeAsync("locales/file.json", TimeSpan.FromDays(14));
await ExpectMaxAgeAsync("fonts/file.ttf", TimeSpan.FromDays(14));
await ExpectMaxAgeAsync("connectors/file.js", TimeSpan.FromDays(14));
await ExpectMaxAgeAsync("scripts/file.js", TimeSpan.FromDays(14));
await ExpectMaxAgeAsync("images/file.avif", TimeSpan.FromDays(7));
response = await client.GetAsync("app-id.json", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
async Task ExpectMaxAgeAsync(string path, TimeSpan maxAge)
{
response = await client.GetAsync(path);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(response.Headers.CacheControl);
Assert.Equal(maxAge, response.Headers.CacheControl.MaxAge);
}
}
private class TempDir([CallerMemberName] string test = null!) : IDisposable
{
public DirectoryInfo Info { get; } = Directory.CreateTempSubdirectory(test);
public void Dispose()
{
Info.Delete(recursive: true);
}
public async Task WriteAsync(string fileName, string content)
{
var fullPath = Path.Join(Info.FullName, fileName);
var directory = Path.GetDirectoryName(fullPath);
if (directory != null)
{
Directory.CreateDirectory(directory);
}
await File.WriteAllTextAsync(fullPath, content, TestContext.Current.CancellationToken);
}
}
}

View File

@@ -0,0 +1,70 @@
-- Creates default user collections for organization users
-- Filters out existing default collections at database level
CREATE OR ALTER 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
GO

View File

@@ -6,6 +6,13 @@ namespace Bit.Server;
public class Program
{
public static void Main(string[] args)
{
var builder = CreateWebHostBuilder(args);
var host = builder.Build();
host.Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args)
{
var config = new ConfigurationBuilder()
.AddCommandLine(args)
@@ -37,7 +44,6 @@ public class Program
builder.UseWebRoot(webRoot);
}
var host = builder.Build();
host.Run();
return builder;
}
}