1
0
mirror of https://github.com/bitwarden/server synced 2025-12-30 15:14:02 +00:00

Merge branch 'arch/seeder-sdk' of github.com:bitwarden/server into arch/seeder-api

# Conflicts:
#	util/Seeder/Factories/UserSeeder.cs
This commit is contained in:
Hinton
2025-10-09 15:53:47 -07:00
42 changed files with 4020 additions and 674 deletions

View File

@@ -484,7 +484,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger self-host build
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@@ -525,7 +525,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger k8s deploy
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |

View File

@@ -32,6 +32,14 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
- name: Install rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
with:
toolchain: stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
- name: Print environment
run: |
dotnet --info

2
.gitignore vendored
View File

@@ -215,6 +215,8 @@ bitwarden_license/src/Sso/wwwroot/assets
**/**.swp
.mono
src/Core/MailTemplates/Mjml/out
NativeMethods.g.cs
util/RustSdk/rust/target
src/Admin/Admin.zip
src/Api/Api.zip

View File

@@ -133,6 +133,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seede
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
@@ -341,6 +342,10 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -403,6 +408,7 @@ Global
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
EndGlobalSection

View File

@@ -10,7 +10,7 @@
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.11.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -53,6 +53,7 @@ services:
- ./.data/postgres/log:/var/log/postgresql
profiles:
- postgres
- ef
mysql:
image: mysql:8.0
@@ -69,6 +70,7 @@ services:
- mysql_dev_data:/var/lib/mysql
profiles:
- mysql
- ef
mariadb:
image: mariadb:10
@@ -76,13 +78,13 @@ services:
- 4306:3306
environment:
MARIADB_USER: maria
MARIADB_PASSWORD: ${MARIADB_ROOT_PASSWORD}
MARIADB_DATABASE: vault_dev
MARIADB_RANDOM_ROOT_PASSWORD: "true"
volumes:
- mariadb_dev_data:/var/lib/mysql
profiles:
- mariadb
- ef
idp:
image: kenchan0130/simplesamlphp:1.19.8
@@ -153,5 +155,6 @@ volumes:
mssql_dev_data:
postgres_dev_data:
mysql_dev_data:
mariadb_dev_data:
rabbitmq_data:
redis_data:

View File

@@ -70,7 +70,7 @@ Foreach ($item in @(
@($mysql, "MySQL", "MySqlMigrations", "mySql", 2),
# MariaDB shares the MySQL connection string in the server config so they are mutually exclusive in that context.
# However they can still be run independently for integration tests.
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 3)
@($mariadb, "MariaDB", "MySqlMigrations", "mySql", 4)
)) {
if (!$item[0] -and !$all) {
continue

View File

@@ -66,7 +66,7 @@ public class AccountBillingVNextController(
}
[HttpPost("subscription")]
[RequireFeature(FeatureFlagKeys.PM23385_UseNewPremiumFlow)]
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
[InjectUser]
public async Task<IResult> CreateSubscriptionAsync(
[BindNever] User user,

View File

@@ -21,7 +21,7 @@ public class SelfHostedAccountBillingController(
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
{
[HttpPost("license")]
[RequireFeature(FeatureFlagKeys.PM23385_UseNewPremiumFlow)]
[RequireFeature(FeatureFlagKeys.PM24996ImplementUpgradeFromFreeDialog)]
[InjectUser]
public async Task<IResult> UploadLicenseAsync(
[BindNever] User user,

View File

@@ -44,6 +44,15 @@ public class BillingSettings
{
public virtual string ApiKey { get; set; }
public virtual string BaseUrl { get; set; }
public virtual string Path { get; set; }
public virtual int PersonaId { get; set; }
public virtual bool UseAnswerWithCitationModels { get; set; } = true;
public virtual SearchSettings SearchSettings { get; set; } = new SearchSettings();
}
public class SearchSettings
{
public virtual string RunSearch { get; set; } = "auto"; // "always", "never", "auto"
public virtual bool RealTime { get; set; } = true;
}
}

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
@@ -35,7 +32,7 @@ public class FreshdeskController : Controller
GlobalSettings globalSettings,
IHttpClientFactory httpClientFactory)
{
_billingSettings = billingSettings?.Value;
_billingSettings = billingSettings?.Value ?? throw new ArgumentNullException(nameof(billingSettings));
_userRepository = userRepository;
_organizationRepository = organizationRepository;
_logger = logger;
@@ -101,7 +98,8 @@ public class FreshdeskController : Controller
customFields[_billingSettings.FreshDesk.OrgFieldName] += $"\n{orgNote}";
}
var planName = GetAttribute<DisplayAttribute>(org.PlanType).Name.Split(" ").FirstOrDefault();
var displayAttribute = GetAttribute<DisplayAttribute>(org.PlanType);
var planName = displayAttribute?.Name?.Split(" ").FirstOrDefault();
if (!string.IsNullOrWhiteSpace(planName))
{
tags.Add(string.Format("Org: {0}", planName));
@@ -159,28 +157,22 @@ public class FreshdeskController : Controller
return Ok();
}
// create the onyx `answer-with-citation` request
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId);
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")),
};
var (_, onyxJsonResponse) = await CallOnyxApi<OnyxAnswerWithCitationResponseModel>(onyxRequest);
// Get response from Onyx AI
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
// the CallOnyxApi will return a null if we have an error response
if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg))
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
{
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
JsonSerializer.Serialize(model),
JsonSerializer.Serialize(onyxRequestModel),
JsonSerializer.Serialize(onyxJsonResponse));
JsonSerializer.Serialize(onyxRequest),
JsonSerializer.Serialize(onyxResponse));
return Ok(); // return ok so we don't retry
}
// add the answer as a note to the ticket
await AddAnswerNoteToTicketAsync(onyxJsonResponse.Answer, model.TicketId);
await AddAnswerNoteToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
return Ok();
}
@@ -206,27 +198,21 @@ public class FreshdeskController : Controller
}
// create the onyx `answer-with-citation` request
var onyxRequestModel = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx.PersonaId);
var onyxRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequestModel, mediaType: new MediaTypeHeaderValue("application/json")),
};
var (_, onyxJsonResponse) = await CallOnyxApi<OnyxAnswerWithCitationResponseModel>(onyxRequest);
var (onyxRequest, onyxResponse) = await GetAnswerFromOnyx(model);
// the CallOnyxApi will return a null if we have an error response
if (onyxJsonResponse?.Answer == null || !string.IsNullOrEmpty(onyxJsonResponse?.ErrorMsg))
if (onyxResponse?.Answer == null || !string.IsNullOrEmpty(onyxResponse?.ErrorMsg))
{
_logger.LogWarning("Error getting answer from Onyx AI. Freshdesk model: {model}\r\n Onyx query {query}\r\nresponse: {response}. ",
JsonSerializer.Serialize(model),
JsonSerializer.Serialize(onyxRequestModel),
JsonSerializer.Serialize(onyxJsonResponse));
JsonSerializer.Serialize(onyxRequest),
JsonSerializer.Serialize(onyxResponse));
return Ok(); // return ok so we don't retry
}
// add the reply to the ticket
await AddReplyToTicketAsync(onyxJsonResponse.Answer, model.TicketId);
await AddReplyToTicketAsync(onyxResponse?.Answer ?? string.Empty, model.TicketId);
return Ok();
}
@@ -356,7 +342,32 @@ public class FreshdeskController : Controller
return await CallFreshdeskApiAsync(request, retriedCount++);
}
private async Task<(HttpResponseMessage, T)> CallOnyxApi<T>(HttpRequestMessage request)
async Task<(OnyxRequestModel onyxRequest, OnyxResponseModel onyxResponse)> GetAnswerFromOnyx(FreshdeskOnyxAiWebhookModel model)
{
// TODO: remove the use of the deprecated answer-with-citation models after we are sure
if (_billingSettings.Onyx.UseAnswerWithCitationModels)
{
var onyxRequest = new OnyxAnswerWithCitationRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
var onyxAnswerWithCitationRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}/query/answer-with-citation", _billingSettings.Onyx.BaseUrl))
{
Content = JsonContent.Create(onyxRequest, mediaType: new MediaTypeHeaderValue("application/json")),
};
var onyxResponse = await CallOnyxApi<OnyxResponseModel>(onyxAnswerWithCitationRequest);
return (onyxRequest, onyxResponse);
}
var request = new OnyxSendMessageSimpleApiRequestModel(model.TicketDescriptionText, _billingSettings.Onyx);
var onyxSimpleRequest = new HttpRequestMessage(HttpMethod.Post,
string.Format("{0}{1}", _billingSettings.Onyx.BaseUrl, _billingSettings.Onyx.Path))
{
Content = JsonContent.Create(request, mediaType: new MediaTypeHeaderValue("application/json")),
};
var onyxSimpleResponse = await CallOnyxApi<OnyxResponseModel>(onyxSimpleRequest);
return (request, onyxSimpleResponse);
}
private async Task<T> CallOnyxApi<T>(HttpRequestMessage request) where T : class, new()
{
var httpClient = _httpClientFactory.CreateClient("OnyxApi");
var response = await httpClient.SendAsync(request);
@@ -365,7 +376,7 @@ public class FreshdeskController : Controller
{
_logger.LogError("Error calling Onyx AI API. Status code: {0}. Response {1}",
response.StatusCode, JsonSerializer.Serialize(response));
return (null, default);
return new T();
}
var responseStr = await response.Content.ReadAsStringAsync();
var responseJson = JsonSerializer.Deserialize<T>(responseStr, options: new JsonSerializerOptions
@@ -373,11 +384,12 @@ public class FreshdeskController : Controller
PropertyNameCaseInsensitive = true,
});
return (response, responseJson);
return responseJson ?? new T();
}
private TAttribute GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
private TAttribute? GetAttribute<TAttribute>(Enum enumValue) where TAttribute : Attribute
{
return enumValue.GetType().GetMember(enumValue.ToString()).First().GetCustomAttribute<TAttribute>();
var memberInfo = enumValue.GetType().GetMember(enumValue.ToString()).FirstOrDefault();
return memberInfo != null ? memberInfo.GetCustomAttribute<TAttribute>() : null;
}
}

View File

@@ -1,35 +1,58 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using static Bit.Billing.BillingSettings;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationRequestModel
public class OnyxRequestModel
{
[JsonPropertyName("messages")]
public List<Message> Messages { get; set; }
[JsonPropertyName("persona_id")]
public int PersonaId { get; set; } = 1;
[JsonPropertyName("retrieval_options")]
public RetrievalOptions RetrievalOptions { get; set; }
public RetrievalOptions RetrievalOptions { get; set; } = new RetrievalOptions();
public OnyxAnswerWithCitationRequestModel(string message, int personaId = 1)
public OnyxRequestModel(OnyxSettings onyxSettings)
{
PersonaId = onyxSettings.PersonaId;
RetrievalOptions.RunSearch = onyxSettings.SearchSettings.RunSearch;
RetrievalOptions.RealTime = onyxSettings.SearchSettings.RealTime;
}
}
/// <summary>
/// This is used with the onyx endpoint /query/answer-with-citation
/// which has been deprecated. This can be removed once later
/// </summary>
public class OnyxAnswerWithCitationRequestModel : OnyxRequestModel
{
[JsonPropertyName("messages")]
public List<Message> Messages { get; set; } = new List<Message>();
public OnyxAnswerWithCitationRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
{
message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
Messages = new List<Message>() { new Message() { MessageText = message } };
RetrievalOptions = new RetrievalOptions();
PersonaId = personaId;
}
}
/// <summary>
/// This is used with the onyx endpoint /chat/send-message-simple-api
/// </summary>
public class OnyxSendMessageSimpleApiRequestModel : OnyxRequestModel
{
[JsonPropertyName("message")]
public string Message { get; set; } = string.Empty;
public OnyxSendMessageSimpleApiRequestModel(string message, OnyxSettings onyxSettings) : base(onyxSettings)
{
Message = message.Replace(Environment.NewLine, " ").Replace('\r', ' ').Replace('\n', ' ');
}
}
public class Message
{
[JsonPropertyName("message")]
public string MessageText { get; set; }
public string MessageText { get; set; } = string.Empty;
[JsonPropertyName("sender")]
public string Sender { get; set; } = "user";

View File

@@ -1,33 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxAnswerWithCitationResponseModel
{
[JsonPropertyName("answer")]
public string Answer { get; set; }
[JsonPropertyName("rephrase")]
public string Rephrase { get; set; }
[JsonPropertyName("citations")]
public List<Citation> Citations { get; set; }
[JsonPropertyName("llm_selected_doc_indices")]
public List<int> LlmSelectedDocIndices { get; set; }
[JsonPropertyName("error_msg")]
public string ErrorMsg { get; set; }
}
public class Citation
{
[JsonPropertyName("citation_num")]
public int CitationNum { get; set; }
[JsonPropertyName("document_id")]
public string DocumentId { get; set; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace Bit.Billing.Models;
public class OnyxResponseModel
{
[JsonPropertyName("answer")]
public string Answer { get; set; } = string.Empty;
[JsonPropertyName("answer_citationless")]
public string AnswerCitationless { get; set; } = string.Empty;
[JsonPropertyName("error_msg")]
public string ErrorMsg { get; set; } = string.Empty;
}

View File

@@ -80,7 +80,13 @@
"onyx": {
"apiKey": "SECRET",
"baseUrl": "https://cloud.onyx.app/api",
"personaId": 7
"path": "/chat/send-message-simple-api",
"useAnswerWithCitationModels": true,
"personaId": 7,
"searchSettings": {
"runSearch": "always",
"realTime": true
}
}
}
}

View File

@@ -135,15 +135,12 @@ public static class AuthenticationSchemes
public static class FeatureFlagKeys
{
/* Admin Console Team */
public const string VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint";
public const string LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission";
public const string PolicyRequirements = "pm-14439-policy-requirements";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
public const string SeparateCustomRolePermissions = "pm-19917-separate-custom-role-permissions";
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string CipherRepositoryBulkResourceCreation = "pm-24951-cipher-repository-bulk-resource-creation-service";
/* Auth Team */
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
@@ -182,9 +179,9 @@ public static class FeatureFlagKeys
public const string PM19422_AllowAutomaticTaxUpdates = "pm-19422-allow-automatic-tax-updates";
public const string PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover";
public const string PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings";
public const string PM23385_UseNewPremiumFlow = "pm-23385-use-new-premium-flow";
public const string PM24996ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog";
public const string PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button";
public const string PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog";
/* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
@@ -234,7 +231,6 @@ public static class FeatureFlagKeys
/* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
public const string SecurityTasks = "security-tasks";
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string DesktopCipherForms = "pm-18520-desktop-cipher-forms";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";

View File

@@ -25,7 +25,6 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
private readonly IdentityErrorDescriber _identityErrorDescriber;
private readonly IWebAuthnCredentialRepository _credentialRepository;
private readonly IPasswordHasher<User> _passwordHasher;
private readonly IFeatureService _featureService;
/// <summary>
/// Instantiates a new <see cref="RotateUserAccountKeysCommand"/>
@@ -61,7 +60,6 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
_identityErrorDescriber = errors;
_credentialRepository = credentialRepository;
_passwordHasher = passwordHasher;
_featureService = featureService;
}
/// <inheritdoc />
@@ -103,15 +101,7 @@ public class RotateUserAccountKeysCommand : IRotateUserAccountKeysCommand
List<UpdateEncryptedDataForKeyRotation> saveEncryptedDataActions = new();
if (model.Ciphers.Any())
{
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
if (useBulkResourceCreationService)
{
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation_vNext(user.Id, model.Ciphers));
}
else
{
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers));
}
saveEncryptedDataActions.Add(_cipherRepository.UpdateForKeyRotation(user.Id, model.Ciphers));
}
if (model.Folders.Any())

View File

@@ -108,15 +108,7 @@ public class ImportCiphersCommand : IImportCiphersCommand
}
// Create it all
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
if (useBulkResourceCreationService)
{
await _cipherRepository.CreateAsync_vNext(importingUserId, ciphers, newFolders);
}
else
{
await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders);
}
await _cipherRepository.CreateAsync(importingUserId, ciphers, newFolders);
// push
await _pushService.PushSyncVaultAsync(importingUserId);
@@ -191,15 +183,7 @@ public class ImportCiphersCommand : IImportCiphersCommand
}
// Create it all
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
if (useBulkResourceCreationService)
{
await _cipherRepository.CreateAsync_vNext(ciphers, newCollections, collectionCiphers, newCollectionUsers);
}
else
{
await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers);
}
await _cipherRepository.CreateAsync(ciphers, newCollections, collectionCiphers, newCollectionUsers);
// push
await _pushService.PushSyncVaultAsync(importingUserId);

View File

@@ -33,28 +33,12 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
Task DeleteByUserIdAsync(Guid userId);
Task DeleteByOrganizationIdAsync(Guid organizationId);
Task UpdateCiphersAsync(Guid userId, IEnumerable<Cipher> ciphers);
/// <inheritdoc cref="UpdateCiphersAsync(Guid, IEnumerable{Cipher})"/>
/// <remarks>
/// This version uses the bulk resource creation service to create the temp table.
/// </remarks>
Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers);
/// <summary>
/// Create ciphers and folders for the specified UserId. Must not be used to create organization owned items.
/// </summary>
Task CreateAsync(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
/// <inheritdoc cref="CreateAsync(Guid, IEnumerable{Cipher}, IEnumerable{Folder})"/>
/// <remarks>
/// This version uses the bulk resource creation service to create the temp tables.
/// </remarks>
Task CreateAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders);
Task CreateAsync(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers);
/// <inheritdoc cref="CreateAsync(IEnumerable{Cipher}, IEnumerable{Collection}, IEnumerable{CollectionCipher}, IEnumerable{CollectionUser})"/>
/// <remarks>
/// This version uses the bulk resource creation service to create the temp tables.
/// </remarks>
Task CreateAsync_vNext(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers);
Task SoftDeleteAsync(IEnumerable<Guid> ids, Guid userId);
Task SoftDeleteByIdsOrganizationIdAsync(IEnumerable<Guid> ids, Guid organizationId);
Task<DateTime> UnarchiveAsync(IEnumerable<Guid> ids, Guid userId);
@@ -92,10 +76,4 @@ public interface ICipherRepository : IRepository<Cipher, Guid>
/// </summary>
Task<IEnumerable<CipherOrganizationDetailsWithCollections>>
GetManyCipherOrganizationDetailsExcludingDefaultCollectionsAsync(Guid organizationId);
/// <inheritdoc cref="UpdateForKeyRotation(Guid, IEnumerable{Cipher})"/>
/// <remarks>
/// This version uses the bulk resource creation service to create the temp table.
/// </remarks>
UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(Guid userId,
IEnumerable<Cipher> ciphers);
}

View File

@@ -644,15 +644,7 @@ public class CipherService : ICipherService
cipherIds.Add(cipher.Id);
}
var useBulkResourceCreationService = _featureService.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation);
if (useBulkResourceCreationService)
{
await _cipherRepository.UpdateCiphersAsync_vNext(sharingUserId, cipherInfos.Select(c => c.cipher));
}
else
{
await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher));
}
await _cipherRepository.UpdateCiphersAsync(sharingUserId, cipherInfos.Select(c => c.cipher));
await _collectionCipherRepository.UpdateCollectionsForCiphersAsync(cipherIds, sharingUserId,
organizationId, collectionIds);

View File

@@ -13,7 +13,6 @@ using Bit.Core.Vault.Models.Data;
using Bit.Core.Vault.Repositories;
using Bit.Infrastructure.Dapper.AdminConsole.Helpers;
using Bit.Infrastructure.Dapper.Repositories;
using Bit.Infrastructure.Dapper.Vault.Helpers;
using Dapper;
using Microsoft.Data.SqlClient;
@@ -383,63 +382,6 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
cmd.ExecuteNonQuery();
}
// Bulk copy data into temp table
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "#TempCipher";
var ciphersTable = ciphers.ToDataTable();
foreach (DataColumn col in ciphersTable.Columns)
{
bulkCopy.ColumnMappings.Add(col.ColumnName, col.ColumnName);
}
ciphersTable.PrimaryKey = new DataColumn[] { ciphersTable.Columns[0] };
await bulkCopy.WriteToServerAsync(ciphersTable);
}
// Update cipher table from temp table
var sql = @"
UPDATE
[dbo].[Cipher]
SET
[Data] = TC.[Data],
[Attachments] = TC.[Attachments],
[RevisionDate] = TC.[RevisionDate],
[Key] = TC.[Key]
FROM
[dbo].[Cipher] C
INNER JOIN
#TempCipher TC ON C.Id = TC.Id
WHERE
C.[UserId] = @UserId
DROP TABLE #TempCipher";
await using (var cmd = new SqlCommand(sql, connection, transaction))
{
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
cmd.ExecuteNonQuery();
}
};
}
/// <inheritdoc />
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(
Guid userId, IEnumerable<Cipher> ciphers)
{
return async (SqlConnection connection, SqlTransaction transaction) =>
{
// Create temp table
var sqlCreateTemp = @"
SELECT TOP 0 *
INTO #TempCipher
FROM [dbo].[Cipher]";
await using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction))
{
cmd.ExecuteNonQuery();
}
// Bulk copy data into temp table
await BulkResourceCreationService.CreateTempCiphersAsync(connection, transaction, ciphers);
@@ -476,88 +418,6 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
return;
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
// 1. Create temp tables to bulk copy into.
var sqlCreateTemp = @"
SELECT TOP 0 *
INTO #TempCipher
FROM [dbo].[Cipher]";
using (var cmd = new SqlCommand(sqlCreateTemp, connection, transaction))
{
cmd.ExecuteNonQuery();
}
// 2. Bulk copy into temp tables.
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "#TempCipher";
var dataTable = BuildCiphersTable(bulkCopy, ciphers);
bulkCopy.WriteToServer(dataTable);
}
// 3. Insert into real tables from temp tables and clean up.
// Intentionally not including Favorites, Folders, and CreationDate
// since those are not meant to be bulk updated at this time
var sql = @"
UPDATE
[dbo].[Cipher]
SET
[UserId] = TC.[UserId],
[OrganizationId] = TC.[OrganizationId],
[Type] = TC.[Type],
[Data] = TC.[Data],
[Attachments] = TC.[Attachments],
[RevisionDate] = TC.[RevisionDate],
[DeletedDate] = TC.[DeletedDate],
[Key] = TC.[Key]
FROM
[dbo].[Cipher] C
INNER JOIN
#TempCipher TC ON C.Id = TC.Id
WHERE
C.[UserId] = @UserId
DROP TABLE #TempCipher";
using (var cmd = new SqlCommand(sql, connection, transaction))
{
cmd.Parameters.Add("@UserId", SqlDbType.UniqueIdentifier).Value = userId;
cmd.ExecuteNonQuery();
}
await connection.ExecuteAsync(
$"[{Schema}].[User_BumpAccountRevisionDate]",
new { Id = userId },
commandType: CommandType.StoredProcedure, transaction: transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers)
{
if (!ciphers.Any())
{
return;
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();
@@ -635,54 +495,6 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
return;
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
if (folders.Any())
{
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[Folder]";
var dataTable = BuildFoldersTable(bulkCopy, folders);
bulkCopy.WriteToServer(dataTable);
}
}
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[Cipher]";
var dataTable = BuildCiphersTable(bulkCopy, ciphers);
bulkCopy.WriteToServer(dataTable);
}
await connection.ExecuteAsync(
$"[{Schema}].[User_BumpAccountRevisionDate]",
new { Id = userId },
commandType: CommandType.StoredProcedure, transaction: transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
public async Task CreateAsync_vNext(Guid userId, IEnumerable<Cipher> ciphers, IEnumerable<Folder> folders)
{
if (!ciphers.Any())
{
return;
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();
@@ -722,75 +534,6 @@ public class CipherRepository : Repository<Cipher, Guid>, ICipherRepository
return;
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[Cipher]";
var dataTable = BuildCiphersTable(bulkCopy, ciphers);
bulkCopy.WriteToServer(dataTable);
}
if (collections.Any())
{
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[Collection]";
var dataTable = BuildCollectionsTable(bulkCopy, collections);
bulkCopy.WriteToServer(dataTable);
}
}
if (collectionCiphers.Any())
{
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[CollectionCipher]";
var dataTable = BuildCollectionCiphersTable(bulkCopy, collectionCiphers);
bulkCopy.WriteToServer(dataTable);
}
}
if (collectionUsers.Any())
{
using (var bulkCopy = new SqlBulkCopy(connection, SqlBulkCopyOptions.KeepIdentity, transaction))
{
bulkCopy.DestinationTableName = "[dbo].[CollectionUser]";
var dataTable = BuildCollectionUsersTable(bulkCopy, collectionUsers);
bulkCopy.WriteToServer(dataTable);
}
}
await connection.ExecuteAsync(
$"[{Schema}].[User_BumpAccountRevisionDateByOrganizationId]",
new { OrganizationId = ciphers.First().OrganizationId },
commandType: CommandType.StoredProcedure, transaction: transaction);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
public async Task CreateAsync_vNext(IEnumerable<Cipher> ciphers, IEnumerable<Collection> collections,
IEnumerable<CollectionCipher> collectionCiphers, IEnumerable<CollectionUser> collectionUsers)
{
if (!ciphers.Any())
{
return;
}
using (var connection = new SqlConnection(ConnectionString))
{
connection.Open();

View File

@@ -168,16 +168,6 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
}
}
/// <inheritdoc cref="CreateAsync(Guid, IEnumerable{Cipher}, IEnumerable{Folder})"/>
/// <remarks>
/// EF does not use the bulk resource creation service, so we need to use the regular create method.
/// </remarks>
public async Task CreateAsync_vNext(Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers,
IEnumerable<Core.Vault.Entities.Folder> folders)
{
await CreateAsync(userId, ciphers, folders);
}
public async Task CreateAsync(IEnumerable<Core.Vault.Entities.Cipher> ciphers,
IEnumerable<Core.Entities.Collection> collections,
IEnumerable<Core.Entities.CollectionCipher> collectionCiphers,
@@ -216,18 +206,6 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
}
}
/// <inheritdoc cref="CreateAsync(IEnumerable{Cipher}, IEnumerable{Collection}, IEnumerable{CollectionCipher}, IEnumerable{CollectionUser})"/>
/// <remarks>
/// EF does not use the bulk resource creation service, so we need to use the regular create method.
/// </remarks>
public async Task CreateAsync_vNext(IEnumerable<Core.Vault.Entities.Cipher> ciphers,
IEnumerable<Core.Entities.Collection> collections,
IEnumerable<Core.Entities.CollectionCipher> collectionCiphers,
IEnumerable<Core.Entities.CollectionUser> collectionUsers)
{
await CreateAsync(ciphers, collections, collectionCiphers, collectionUsers);
}
public async Task DeleteAsync(IEnumerable<Guid> ids, Guid userId)
{
await ToggleDeleteCipherStatesAsync(ids, userId, CipherStateAction.HardDelete);
@@ -986,15 +964,6 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
}
}
/// <inheritdoc cref="UpdateCiphersAsync(Guid, IEnumerable{Cipher})"/>
/// <remarks>
/// EF does not use the bulk resource creation service, so we need to use the regular update method.
/// </remarks>
public async Task UpdateCiphersAsync_vNext(Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers)
{
await UpdateCiphersAsync(userId, ciphers);
}
public async Task UpdatePartialAsync(Guid id, Guid userId, Guid? folderId, bool favorite)
{
using (var scope = ServiceScopeFactory.CreateScope())
@@ -1107,16 +1076,6 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
return result;
}
/// <inheritdoc cref="UpdateForKeyRotation(Guid, IEnumerable{Cipher})"/>
/// <remarks>
/// EF does not use the bulk resource creation service, so we need to use the regular update method.
/// </remarks>
public UpdateEncryptedDataForKeyRotation UpdateForKeyRotation_vNext(
Guid userId, IEnumerable<Core.Vault.Entities.Cipher> ciphers)
{
return UpdateForKeyRotation(userId, ciphers);
}
public async Task UpsertAsync(CipherDetails cipher)
{
if (cipher.Id.Equals(default))

View File

@@ -169,7 +169,7 @@ public class FreshdeskControllerTests
[BitAutoData(WebhookKey)]
public async Task PostWebhookOnyxAi_success(
string freshdeskWebhookKey, FreshdeskOnyxAiWebhookModel model,
OnyxAnswerWithCitationResponseModel onyxResponse,
OnyxResponseModel onyxResponse,
SutProvider<FreshdeskController> sutProvider)
{
var billingSettings = sutProvider.GetDependency<IOptions<BillingSettings>>().Value;

View File

@@ -1,15 +1,20 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Models.Business;
using Bit.Core.Settings;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Models.Business;
public class OrganizationLicenseTests
{
/// <summary>
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
/// it matches the Organization it was generated for.
@@ -33,4 +38,217 @@ public class OrganizationLicenseTests
});
Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings));
}
/// <summary>
/// Known good GetDataBytes output for hash data (forHash: true) for all OrganizationLicense versions.
/// These values were verified to be correct on initial implementation and serve as regression baselines.
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
/// </summary>
private static readonly Dictionary<int, string> _knownGoodOrganizationLicenseHashData = new()
{
{ 1, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UseTotp:true|Version:1" },
{ 2, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:2" },
{ 3, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:3" },
{ 4, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:4" },
{ 5, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:5" },
{ 6, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseTotp:true|Version:6" },
{ 7, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:7" },
{ 8, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:8" },
{ 9, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:9" },
{ 10, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:10" },
{ 11, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:11" },
{ 12, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:12" },
{ 13, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:13" },
{ 14, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:14" },
{ 15, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:15" },
{ 16, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:16" }
};
/// <summary>
/// Known good GetDataBytes output for signature data (forHash: false) for all OrganizationLicense versions.
/// These values were verified to be correct on initial implementation and serve as regression baselines.
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
/// </summary>
private static readonly Dictionary<int, string> _knownGoodOrganizationLicenseSignatureData = new()
{
{ 1, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:WSyM/Q+vgOuWeF6XBH+RSfUqvf7NDtP3fgNfcbXYqKc=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UseTotp:true|Version:1" },
{ 2, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:n4g3leUf/egbnKk+/VgkJTvdxw2YRH6/zGgx89h+J60=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:2" },
{ 3, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:zDoMNV/c8YpUypc+FmBoPyj73qOsg4snsMOJDcKFp9k=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:3" },
{ 4, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:Y2sP9phSZ9GqbCC+PMp1KdnUhjfNaqNg6uzfUydrKZM=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:4" },
{ 5, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:PudZKNV7YAWJogm8BJf3wZIL+lESf3qzV/pQlZPPJjY=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsersGetPremium:true|UseTotp:true|Version:5" },
{ 6, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:7SjSYQENeAW4pUnXtsPaux2uipIWNWJz9VIrNW2gVsI=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseTotp:true|Version:6" },
{ 7, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:ujf4/zlDXv1g6ktlk9XBj/u3BkRZG+p5I00piGDiWp8=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:7" },
{ 8, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:GEM3AyWbQknnlDtoxyhw0QK7edYS2C/bffX5+p4G9ig=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:8" },
{ 9, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:5SF14wtEieiA9hjj+BTcrggHcx7dLEGbH+HLksvK79o=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseSso:true|UseTotp:true|Version:9" },
{ 10, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:NmbIpfiZUNxSvwbaolbUmItQCHIcVCTjfraR/NBlmvE=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:10" },
{ 11, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|Expires:1740787200|Hash:dGZBQT/PORsuT/W2oRrngcjTboTyfZZVpDZBHshVK6Y=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:11" },
{ 12, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:rWecCXB0kuqi/RW3C8u2rLZRDMR49W3W4Q3eL2tZ3j8=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSso:true|UseTotp:true|Version:12" },
{ 13, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:15fwM5v5Ba+t7JlD4ToYvtZmAoShWC3DrOD0lM5kXGE=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:13" },
{ 14, "license:organization|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:2bTNBiH2G/Nzv6UVD1BNJQBGjT9et0UO8ComQofS8uo=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:14" },
{ 15, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:3VjOyWJu38N4epIzhDzjRR80zQ651wnYkQCd+DIzeAs=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:15" },
{ 16, "license:organization|AllowAdminAccessToAllCollectionItems:true|BillingEmail:myBillingEmail|BusinessName:|Enabled:true|ExpirationWithoutGracePeriod:|Expires:1740787200|Hash:Oo5KFBoX8pMcklJ4oJAqgv77/WA8+gDPxq6+/Fjffwc=|Id:12300000-0000-0000-0000-000000000456|InstallationId:78900000-0000-0000-0000-000000000123|Issued:1758888001|LicenseKey:myLicenseKey|LimitCollectionCreationDeletion:true|MaxCollections:2|MaxStorageGb:100|Name:myOrg|Plan:myPlan|PlanType:11|Refresh:1761480001|Seats:10|SelfHost:true|SmSeats:5|SmServiceAccounts:8|Trial:false|Use2fa:true|UseApi:true|UseCustomPermissions:true|UseDirectory:true|UseEvents:true|UseGroups:true|UseKeyConnector:true|UsePasswordManager:true|UsePolicies:true|UseResetPassword:true|UsersGetPremium:true|UseScim:true|UseSecretsManager:true|UseSso:true|UseTotp:true|Version:16" }
};
/// <summary>
/// Regression test that verifies GetDataBytes output for hash data (forHash: true) remains stable across all OrganizationLicense versions.
/// This protects against accidental changes to the data format that would break backward compatibility.
/// If this test fails, it means the hash data format has changed and existing licenses may no longer validate.
/// </summary>
[Fact]
public void OrganizationLicense_GetDataBytes_HashData_AllVersions()
{
// Verify each version produces the expected hash data format
for (var version = 1; version <= 16; version++)
{
var license = CreateDeterministicOrganizationLicense(version);
var actualHashData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: true));
Assert.Equal(_knownGoodOrganizationLicenseHashData[version], actualHashData);
}
}
/// <summary>
/// Regression test that verifies GetDataBytes output for signature data (forHash: false) remains stable across all OrganizationLicense versions.
/// This protects against accidental changes to the data format that would break backward compatibility.
/// If this test fails, it means the signature data format has changed and existing licenses may no longer validate.
/// </summary>
[Fact]
public void OrganizationLicense_GetDataBytes_SignatureData_AllVersions()
{
// Verify each version produces the expected signature data format
for (var version = 1; version <= 16; version++)
{
var license = CreateDeterministicOrganizationLicense(version);
var actualSignatureData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: false));
Assert.Equal(_knownGoodOrganizationLicenseSignatureData[version], actualSignatureData);
}
}
/// <summary>
/// Validates that the OrganizationLicense version remains frozen at version 15.
/// License versions should no longer be incremented. Use the JWT Token property to add new claims instead.
/// If this test fails, it means someone attempted to increment the license version, which is no longer allowed.
/// </summary>
[Fact]
public void OrganizationLicense_CurrentVersion_ShouldRemainFrozen()
{
const int expectedVersion = 15;
var actualVersion = OrganizationLicense.CurrentLicenseFileVersion;
Assert.True(actualVersion == expectedVersion, $@"
ERROR: OrganizationLicense.CurrentLicenseFileVersion has been changed from {expectedVersion} to {actualVersion}
License versions are now frozen and should not be incremented.
Instead of incrementing the version:
- Use the JWT Token property to add new claims
- Add your new capabilities as claims in the Token
- This allows for more flexible licensing without breaking backward compatibility
If you believe you need to change the version for a valid reason, please discuss with the team first.
");
}
/// <summary>
/// Creates a deterministic OrganizationLicense for testing hash values.
/// All property values are fixed to ensure reproducible hashes.
/// </summary>
private static OrganizationLicense CreateDeterministicOrganizationLicense(int version)
{
var organization = CreateDeterministicOrganization();
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
var installationId = new Guid("78900000-0000-0000-0000-000000000123");
var mockLicensingService = CreateMockLicensingService();
var license = new OrganizationLicense(organization, subscriptionInfo, installationId, mockLicensingService, version);
// Override timestamps to deterministic values (constructor sets them to DateTime.UtcNow)
license.Issued = new DateTime(2025, 9, 26, 12, 0, 1, DateTimeKind.Utc); // Corresponds to 1759501361 Unix timestamp
license.Refresh = new DateTime(2025, 10, 26, 12, 0, 1, DateTimeKind.Utc); // Corresponds to 1762093361 Unix timestamp
// Recalculate hash with the deterministic Issued/Refresh values
license.Hash = Convert.ToBase64String(license.ComputeHash());
license.Signature = Convert.ToBase64String(mockLicensingService.SignLicense(license));
return license;
}
/// <summary>
/// Creates an Organization with deterministic property values for reproducible testing.
/// </summary>
private static Organization CreateDeterministicOrganization()
{
return new Organization
{
Id = new Guid("12300000-0000-0000-0000-000000000456"),
Identifier = "myIdentifier",
Name = "myOrg",
BillingEmail = "myBillingEmail",
Plan = "myPlan",
PlanType = PlanType.EnterpriseAnnually2020,
Seats = 10,
MaxCollections = 2,
UsePolicies = true,
UseSso = true,
UseKeyConnector = true,
UseScim = true,
UseGroups = true,
UseEvents = true,
UseDirectory = true,
UseTotp = true,
Use2fa = true,
UseApi = true,
UseResetPassword = true,
MaxStorageGb = 100,
SelfHost = true,
UsersGetPremium = true,
UseCustomPermissions = true,
Enabled = true,
LicenseKey = "myLicenseKey",
UsePasswordManager = true,
UseSecretsManager = true,
SmSeats = 5,
SmServiceAccounts = 8,
UseRiskInsights = false,
LimitCollectionCreation = true,
LimitCollectionDeletion = true,
AllowAdminAccessToAllCollectionItems = true,
UseOrganizationDomains = true,
UseAdminSponsoredFamilies = false
};
}
/// <summary>
/// Creates a SubscriptionInfo with deterministic dates for reproducible testing.
/// </summary>
private static SubscriptionInfo CreateDeterministicSubscriptionInfo()
{
var stripeSubscription = new Subscription
{
Status = "active",
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
};
return new SubscriptionInfo
{
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice
{
Date = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
},
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription)
};
}
/// <summary>
/// Creates a mock ILicensingService that returns a deterministic signature.
/// </summary>
private static ILicensingService CreateMockLicensingService()
{
var mockService = Substitute.For<ILicensingService>();
mockService.SignLicense(Arg.Any<ILicense>())
.Returns([0x00, 0x01, 0x02, 0x03]); // Dummy signature for hash testing
return mockService;
}
}

View File

@@ -0,0 +1,168 @@
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Models.Business;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Core.Test.Billing.Models.Business;
public class UserLicenseTests
{
/// <summary>
/// Known good GetDataBytes output for hash data (forHash: true) for UserLicense version 1.
/// This value was verified to be correct on initial implementation and serves as a regression baseline.
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
/// </summary>
private const string _knownGoodUserLicenseHashData = "license:user|Email:test@example.com|Expires:1736208000|Id:12300000-0000-0000-0000-000000000789|LicenseKey:myUserLicenseKey|MaxStorageGb:10|Name:Test User|Premium:true|Trial:false|Version:1";
/// <summary>
/// Known good GetDataBytes output for signature data (forHash: false) for UserLicense version 1.
/// This value was verified to be correct on initial implementation and serves as a regression baseline.
/// NOTE: License versions are now frozen. Use the JWT Token property to add new claims instead of incrementing the version.
/// </summary>
private const string _knownGoodUserLicenseSignatureData = "license:user|Email:test@example.com|Expires:1736208000|Hash:oZEopNmWvWQNE3Lnsh/LP2OPo6+IHxjTcpdIse/viQk=|Id:12300000-0000-0000-0000-000000000789|Issued:1758888041|LicenseKey:myUserLicenseKey|MaxStorageGb:10|Name:Test User|Premium:true|Refresh:1735603200|Trial:false|Version:1";
/// <summary>
/// Regression test that verifies GetDataBytes output for hash data (forHash: true) remains stable for UserLicense version 1.
/// This protects against accidental changes to the data format that would break backward compatibility.
/// If this test fails, it means the hash data format has changed and existing licenses may no longer validate.
/// </summary>
[Fact]
public void UserLicense_GetDataBytes_HashData_Version1()
{
var license = CreateDeterministicUserLicense();
var actualHashData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: true));
Assert.Equal(_knownGoodUserLicenseHashData, actualHashData);
}
/// <summary>
/// Regression test that verifies GetDataBytes output for signature data (forHash: false) remains stable for UserLicense version 1.
/// This protects against accidental changes to the data format that would break backward compatibility.
/// If this test fails, it means the signature data format has changed and existing licenses may no longer validate.
/// </summary>
[Fact]
public void UserLicense_GetDataBytes_SignatureData_Version1()
{
var license = CreateDeterministicUserLicense();
var actualSignatureData = System.Text.Encoding.UTF8.GetString(license.GetDataBytes(forHash: false));
Assert.Equal(_knownGoodUserLicenseSignatureData, actualSignatureData);
}
/// <summary>
/// Validates that the UserLicense version remains frozen at version 1.
/// License versions should no longer be incremented. Use the JWT Token property to add new claims instead.
/// If this test fails, it means someone attempted to add version 2 support, which is no longer allowed.
/// </summary>
[Fact]
public void UserLicense_CurrentVersion_ShouldRemainFrozen()
{
const int expectedMaxVersion = 1;
var user = CreateDeterministicUser();
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
var mockLicensingService = CreateMockLicensingService();
// Verify that version 2 is NOT supported (should throw NotSupportedException)
var exception = Assert.Throws<NotSupportedException>(() =>
new UserLicense(user, subscriptionInfo, mockLicensingService, version: 2));
// If the exception message changes or we don't get an exception, fail with helpful guidance
if (exception == null)
{
var errorMessage = $@"
ERROR: UserLicense now supports version 2 or higher
License versions are now frozen and should not be incremented.
Instead of incrementing the version:
- Use the JWT Token property to add new claims
- Add your new capabilities as claims in the Token
- This allows for more flexible licensing without breaking backward compatibility
If you believe you need to change the version for a valid reason, please discuss with the team first.
";
Assert.Fail(errorMessage);
}
// Verify we still support version 1
var license = new UserLicense(user, subscriptionInfo, mockLicensingService, version: expectedMaxVersion);
Assert.NotNull(license);
}
/// <summary>
/// Creates a deterministic UserLicense for testing hash values.
/// All property values are fixed to ensure reproducible hashes.
/// </summary>
private static UserLicense CreateDeterministicUserLicense()
{
var user = CreateDeterministicUser();
var subscriptionInfo = CreateDeterministicSubscriptionInfo();
var mockLicensingService = CreateMockLicensingService();
var license = new UserLicense(user, subscriptionInfo, mockLicensingService, version: 1);
// Override timestamps to deterministic values (constructor sets them to DateTime.UtcNow)
license.Issued = new DateTime(2025, 9, 26, 12, 0, 41, DateTimeKind.Utc); // Corresponds to 1759502041 Unix timestamp
license.Refresh = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc); // Corresponds to 1735603200 Unix timestamp
// Recalculate hash with the deterministic Issued/Refresh values
license.Hash = Convert.ToBase64String(license.ComputeHash());
license.Signature = Convert.ToBase64String(mockLicensingService.SignLicense(license));
return license;
}
/// <summary>
/// Creates a User with deterministic property values for reproducible testing.
/// </summary>
private static User CreateDeterministicUser()
{
return new User
{
Id = new Guid("12300000-0000-0000-0000-000000000789"),
Name = "Test User",
Email = "test@example.com",
LicenseKey = "myUserLicenseKey",
Premium = true,
MaxStorageGb = 10,
PremiumExpirationDate = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
};
}
/// <summary>
/// Creates a SubscriptionInfo with deterministic dates for reproducible testing.
/// </summary>
private static SubscriptionInfo CreateDeterministicSubscriptionInfo()
{
var stripeSubscription = new Subscription
{
Status = "active",
TrialStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
TrialEnd = new DateTime(2024, 2, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodStart = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
CurrentPeriodEnd = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
};
return new SubscriptionInfo
{
UpcomingInvoice = new SubscriptionInfo.BillingUpcomingInvoice
{
Date = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc)
},
Subscription = new SubscriptionInfo.BillingSubscription(stripeSubscription)
};
}
/// <summary>
/// Creates a mock ILicensingService that returns a deterministic signature.
/// </summary>
private static ILicensingService CreateMockLicensingService()
{
var mockService = Substitute.For<ILicensingService>();
mockService.SignLicense(Arg.Any<ILicense>())
.Returns([0x00, 0x01, 0x02, 0x03]); // Dummy signature for hash testing
return mockService;
}
}

View File

@@ -53,38 +53,6 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
Guid importingUserId,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IPolicyService>()
.AnyPoliciesApplicableToUserAsync(importingUserId, PolicyType.OrganizationDataOwnership)
.Returns(false);
sutProvider.GetDependency<IFolderRepository>()
.GetManyByUserIdAsync(importingUserId)
.Returns(new List<Folder>());
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
var folderRelationships = new List<KeyValuePair<int, int>>();
// Act
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
// Assert
await sutProvider.GetDependency<ICipherRepository>()
.Received(1)
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
Guid importingUserId,
@@ -117,42 +85,6 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_WithBulkResourceCreationServiceEnabled_WithPolicyRequirementsEnabled_WithOrganizationDataOwnershipPolicyDisabled_Success(
Guid importingUserId,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.PolicyRequirements)
.Returns(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<OrganizationDataOwnershipPolicyRequirement>(importingUserId)
.Returns(new OrganizationDataOwnershipPolicyRequirement(
OrganizationDataOwnershipState.Disabled,
[]));
sutProvider.GetDependency<IFolderRepository>()
.GetManyByUserIdAsync(importingUserId)
.Returns(new List<Folder>());
var folders = new List<Folder> { new Folder { UserId = importingUserId } };
var folderRelationships = new List<KeyValuePair<int, int>>();
await sutProvider.Sut.ImportIntoIndividualVaultAsync(folders, ciphers, folderRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>()
.Received(1)
.CreateAsync_vNext(importingUserId, ciphers, Arg.Any<List<Folder>>());
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoIndividualVaultAsync_ThrowsBadRequestException(
List<Folder> folders,
@@ -259,66 +191,6 @@ public class ImportCiphersAsyncCommandTests
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_WithBulkResourceCreationServiceEnabled_Success(
Organization organization,
Guid importingUserId,
OrganizationUser importingOrganizationUser,
List<Collection> collections,
List<CipherDetails> ciphers,
SutProvider<ImportCiphersCommand> sutProvider)
{
organization.MaxCollections = null;
importingOrganizationUser.OrganizationId = organization.Id;
foreach (var collection in collections)
{
collection.OrganizationId = organization.Id;
}
foreach (var cipher in ciphers)
{
cipher.OrganizationId = organization.Id;
}
KeyValuePair<int, int>[] collectionRelationships = {
new(0, 0),
new(1, 1),
new(2, 2)
};
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetByOrganizationAsync(organization.Id, importingUserId)
.Returns(importingOrganizationUser);
// Set up a collection that already exists in the organization
sutProvider.GetDependency<ICollectionRepository>()
.GetManyByOrganizationIdAsync(organization.Id)
.Returns(new List<Collection> { collections[0] });
await sutProvider.Sut.ImportIntoOrganizationalVaultAsync(collections, ciphers, collectionRelationships, importingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).CreateAsync_vNext(
ciphers,
Arg.Is<IEnumerable<Collection>>(cols => cols.Count() == collections.Count - 1 &&
!cols.Any(c => c.Id == collections[0].Id) && // Check that the collection that already existed in the organization was not added
cols.All(c => collections.Any(x => c.Name == x.Name))),
Arg.Is<IEnumerable<CollectionCipher>>(c => c.Count() == ciphers.Count),
Arg.Is<IEnumerable<CollectionUser>>(cus =>
cus.Count() == collections.Count - 1 &&
!cus.Any(cu => cu.CollectionId == collections[0].Id) && // Check that access was not added for the collection that already existed in the organization
cus.All(cu => cu.OrganizationUserId == importingOrganizationUser.Id && cu.Manage == true)));
await sutProvider.GetDependency<IPushNotificationService>().Received(1).PushSyncVaultAsync(importingUserId);
}
[Theory, BitAutoData]
public async Task ImportIntoOrganizationalVaultAsync_ThrowsBadRequestException(
Organization organization,

View File

@@ -674,32 +674,6 @@ public class CipherServiceTests
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
[Theory]
[BitAutoData("")]
[BitAutoData("Correct Time")]
public async Task ShareManyAsync_CorrectRevisionDate_WithBulkResourceCreationServiceEnabled_Passes(string revisionDateString,
SutProvider<CipherService> sutProvider, IEnumerable<CipherDetails> ciphers, Organization organization, List<Guid> collectionIds)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id)
.Returns(new Organization
{
PlanType = PlanType.EnterpriseAnnually,
MaxStorageGb = 100
});
var cipherInfos = ciphers.Select(c => (c,
string.IsNullOrEmpty(revisionDateString) ? null : (DateTime?)c.RevisionDate));
var sharingUserId = ciphers.First().UserId.Value;
await sutProvider.Sut.ShareManyAsync(cipherInfos, organization.Id, collectionIds, sharingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
[Theory]
[BitAutoData]
public async Task RestoreAsync_UpdatesUserCipher(Guid restoringUserId, CipherDetails cipher, SutProvider<CipherService> sutProvider)
@@ -1120,33 +1094,6 @@ public class CipherServiceTests
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
[Theory, BitAutoData]
public async Task ShareManyAsync_PaidOrgWithAttachment_WithBulkResourceCreationServiceEnabled_Passes(SutProvider<CipherService> sutProvider,
IEnumerable<CipherDetails> ciphers, Guid organizationId, List<Guid> collectionIds)
{
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.CipherRepositoryBulkResourceCreation)
.Returns(true);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId)
.Returns(new Organization
{
PlanType = PlanType.EnterpriseAnnually,
MaxStorageGb = 100
});
ciphers.FirstOrDefault().Attachments =
"{\"attachment1\":{\"Size\":\"250\",\"FileName\":\"superCoolFile\","
+ "\"Key\":\"superCoolFile\",\"ContainerName\":\"testContainer\",\"Validated\":false}}";
var cipherInfos = ciphers.Select(c => (c,
(DateTime?)c.RevisionDate));
var sharingUserId = ciphers.First().UserId.Value;
await sutProvider.Sut.ShareManyAsync(cipherInfos, organizationId, collectionIds, sharingUserId);
await sutProvider.GetDependency<ICipherRepository>().Received(1).UpdateCiphersAsync_vNext(sharingUserId,
Arg.Is<IEnumerable<Cipher>>(arg => !arg.Except(ciphers).Any()));
}
private class SaveDetailsAsyncDependencies
{
public CipherDetails CipherDetails { get; set; }

View File

@@ -949,36 +949,7 @@ public class CipherRepositoryTests
}
[DatabaseTheory, DatabaseData]
public async Task UpdateCiphersAsync_Works(ICipherRepository cipherRepository, IUserRepository userRepository)
{
var user = await userRepository.CreateAsync(new User
{
Name = "Test User",
Email = $"test+{Guid.NewGuid()}@email.com",
ApiKey = "TEST",
SecurityStamp = "stamp",
});
var cipher1 = await CreatePersonalCipher(user, cipherRepository);
var cipher2 = await CreatePersonalCipher(user, cipherRepository);
cipher1.Type = CipherType.SecureNote;
cipher2.Attachments = "new_attachments";
await cipherRepository.UpdateCiphersAsync(user.Id, [cipher1, cipher2]);
var updatedCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id);
var updatedCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id);
Assert.NotNull(updatedCipher1);
Assert.NotNull(updatedCipher2);
Assert.Equal(CipherType.SecureNote, updatedCipher1.Type);
Assert.Equal("new_attachments", updatedCipher2.Attachments);
}
[DatabaseTheory, DatabaseData]
public async Task CreateAsync_vNext_WithFolders_Works(
public async Task CreateAsync_WithFolders_Works(
IUserRepository userRepository, ICipherRepository cipherRepository, IFolderRepository folderRepository)
{
// Arrange
@@ -996,7 +967,7 @@ public class CipherRepositoryTests
var cipher2 = new Cipher { Id = CoreHelpers.GenerateComb(), Type = CipherType.SecureNote, UserId = user.Id, Data = "" };
// Act
await cipherRepository.CreateAsync_vNext(
await cipherRepository.CreateAsync(
userId: user.Id,
ciphers: [cipher1, cipher2],
folders: [folder1, folder2]);
@@ -1014,7 +985,7 @@ public class CipherRepositoryTests
}
[DatabaseTheory, DatabaseData]
public async Task CreateAsync_vNext_WithCollectionsAndUsers_Works(
public async Task CreateAsync_WithCollectionsAndUsers_Works(
IOrganizationRepository orgRepository,
IOrganizationUserRepository orgUserRepository,
ICollectionRepository collectionRepository,
@@ -1059,7 +1030,7 @@ public class CipherRepositoryTests
};
// Act
await cipherRepository.CreateAsync_vNext(
await cipherRepository.CreateAsync(
ciphers: [cipher],
collections: [collection],
collectionCiphers: [collectionCipher],
@@ -1084,7 +1055,7 @@ public class CipherRepositoryTests
}
[DatabaseTheory, DatabaseData]
public async Task UpdateCiphersAsync_vNext_Works(
public async Task UpdateCiphersAsync_Works(
IUserRepository userRepository, ICipherRepository cipherRepository)
{
// Arrange
@@ -1110,7 +1081,7 @@ public class CipherRepositoryTests
c2.Attachments = expectedNewAttachments;
// Act
await cipherRepository.UpdateCiphersAsync_vNext(user.Id, [c1, c2]);
await cipherRepository.UpdateCiphersAsync(user.Id, [c1, c2]);
// Assert
var updated1 = await cipherRepository.GetByIdAsync(c1.Id);

View File

@@ -26,6 +26,7 @@ public class Program
// Create service provider with necessary services
var services = new ServiceCollection();
ServiceCollectionExtension.ConfigureServices(services);
var serviceProvider = services.BuildServiceProvider();
// Get a scoped DB context

View File

@@ -0,0 +1,58 @@
using System.Reflection;
using System.Runtime.InteropServices;
namespace Bit.RustSDK;
public static partial class NativeMethods
{
// https://docs.microsoft.com/en-us/dotnet/standard/native-interop/cross-platform
// Library path will search
// win => __DllName, __DllName.dll
// linux, osx => __DllName.so, __DllName.dylib
static NativeMethods()
{
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, DllImportResolver);
}
static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName != __DllName) return IntPtr.Zero;
var path = "runtimes/";
var extension = "";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
path += "win-";
extension = ".dll";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
path += "osx-";
extension = ".dylib";
}
else
{
path += "linux-";
extension = ".so";
}
if (RuntimeInformation.ProcessArchitecture == Architecture.X86)
{
path += "x86";
}
else if (RuntimeInformation.ProcessArchitecture == Architecture.X64)
{
path += "x64";
}
else if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
path += "arm64";
}
path += "/native/" + __DllName + extension;
return NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, path), assembly, searchPath);
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Bit.RustSDK</RootNamespace>
<UserSecretsId>Bit.RustSDK</UserSecretsId>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<Content Include="rust/target/release/libsdk*.dylib">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/osx-arm64/native/libsdk.dylib</Link>
</Content>
<Content Include="./rust/target/release/libsdk*.so">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/linux-x64/native/libsdk.dylib</Link>
</Content>
<Content Include="./rust/target/release/libsdk*.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<PackageCopyToOutput>true</PackageCopyToOutput>
<Link>runtimes/windows-x64/native/libsdk.dylib</Link>
</Content>
<!-- This is a work around because this file is compiled by the PreBuild event below, and won't
always be detected -->
<Compile Remove="NativeMethods.g.cs" />
</ItemGroup>
<Target Name="PreBuild" BeforeTargets="PreBuildEvent">
<Exec Command="cargo build --release" WorkingDirectory="$(ProjectDir)/rust" />
<ItemGroup>
<Compile Include="NativeMethods.g.cs" />
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,19 @@
namespace Bit.RustSDK;
/// <summary>
/// Exception thrown when the Rust SDK operations fail
/// </summary>
public class RustSdkException : Exception
{
public RustSdkException() : base("An error occurred in the Rust SDK operation")
{
}
public RustSdkException(string message) : base(message)
{
}
public RustSdkException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,104 @@
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
namespace Bit.RustSDK;
public class UserKeys
{
public required string MasterPasswordHash { get; set; }
/// <summary>
/// Base64 encoded UserKey
/// </summary>
public required string Key { get; set; }
public required string EncryptedUserKey { get; set; }
public required string PublicKey { get; set; }
public required string PrivateKey { get; set; }
}
public class OrganizationKeys
{
/// <summary>
/// Base64 encoded SymmetricCryptoKey
/// </summary>
public required string Key { get; set; }
public required string PublicKey { get; set; }
public required string PrivateKey { get; set; }
}
/// <summary>
/// Service implementation that provides a C# friendly interface to the Rust SDK
/// </summary>
public class RustSdkService
{
private static readonly JsonSerializerOptions CaseInsensitiveOptions = new()
{
PropertyNameCaseInsensitive = true
};
public unsafe UserKeys GenerateUserKeys(string email, string password)
{
var emailBytes = StringToRustString(email);
var passwordBytes = StringToRustString(password);
fixed (byte* emailPtr = emailBytes)
fixed (byte* passwordPtr = passwordBytes)
{
var resultPtr = NativeMethods.generate_user_keys(emailPtr, passwordPtr);
var result = TakeAndDestroyRustString(resultPtr);
return JsonSerializer.Deserialize<UserKeys>(result, CaseInsensitiveOptions)!;
}
}
public unsafe OrganizationKeys GenerateOrganizationKeys()
{
var resultPtr = NativeMethods.generate_organization_keys();
var result = TakeAndDestroyRustString(resultPtr);
return JsonSerializer.Deserialize<OrganizationKeys>(result, CaseInsensitiveOptions)!;
}
public unsafe string GenerateUserOrganizationKey(string userKey, string orgKey)
{
var userKeyBytes = StringToRustString(userKey);
var orgKeyBytes = StringToRustString(orgKey);
fixed (byte* userKeyPtr = userKeyBytes)
fixed (byte* orgKeyPtr = orgKeyBytes)
{
var resultPtr = NativeMethods.generate_user_organization_key(userKeyPtr, orgKeyPtr);
var result = TakeAndDestroyRustString(resultPtr);
return result;
}
}
private static byte[] StringToRustString(string str)
{
return Encoding.UTF8.GetBytes(str + '\0');
}
private static unsafe string TakeAndDestroyRustString(byte* ptr)
{
if (ptr == null)
{
throw new RustSdkException("Pointer is null");
}
var result = Marshal.PtrToStringUTF8((IntPtr)ptr);
NativeMethods.free_c_string(ptr);
if (result == null)
{
throw new RustSdkException("Failed to convert native result to string");
}
return result;
}
}

View File

@@ -0,0 +1,21 @@
namespace Bit.RustSDK;
/// <summary>
/// Factory for creating Rust SDK service instances
/// </summary>
public static class RustSdkServiceFactory
{
/// <summary>
/// Creates a singleton instance of the Rust SDK service (thread-safe)
/// </summary>
/// <returns>A singleton IRustSdkService instance</returns>
public static RustSdkService CreateSingleton()
{
return SingletonHolder.Instance;
}
private static class SingletonHolder
{
internal static readonly RustSdkService Instance = new();
}
}

2938
util/RustSdk/rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
[package]
name = "sdk"
publish = false
version = "0.1.0"
authors = ["Bitwarden Inc"]
edition = "2021"
homepage = "https://bitwarden.com"
repository = "https://github.com/bitwarden/server"
[lib]
crate-type = ["cdylib"]
[dependencies]
base64 = "0.22.1"
bitwarden-core = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "29c6158636d50141788e41736d15f2f6c7bc7fa8" }
bitwarden-crypto = { git = "https://github.com/bitwarden/sdk-internal.git", rev = "29c6158636d50141788e41736d15f2f6c7bc7fa8" }
serde = "=1.0.219"
serde_json = "=1.0.141"
[build-dependencies]
csbindgen = "=1.9.3"
# Compile all dependencies with some optimizations when building this crate on debug
# This slows down clean builds by about 50%, but the resulting binaries can be orders of magnitude faster
# As clean builds won't occur very often, this won't slow down the development process
[profile.dev.package."*"]
opt-level = 2
[profile.release]
codegen-units = 1
lto = true
opt-level = 3

View File

@@ -0,0 +1,9 @@
fn main() {
csbindgen::Builder::default()
.input_extern_file("src/lib.rs")
.csharp_dll_name("libsdk")
.csharp_namespace("Bit.RustSDK")
.csharp_class_accessibility("public")
.generate_csharp_file("../NativeMethods.g.cs")
.unwrap();
}

View File

@@ -0,0 +1,151 @@
#![allow(clippy::missing_safety_doc)]
use std::ffi::{c_char, CStr, CString};
use base64::{engine::general_purpose::STANDARD, Engine};
use bitwarden_crypto::{
pbkdf2, AsymmetricCryptoKey, AsymmetricPublicCryptoKey, BitwardenLegacyKeyBytes, HashPurpose,
KeyEncryptable, MasterKey, RsaKeyPair, SpkiPublicKeyBytes, SymmetricCryptoKey,
UnsignedSharedKey, UserKey,
};
#[no_mangle]
pub unsafe extern "C" fn generate_user_keys(
email: *const c_char,
password: *const c_char,
) -> *const c_char {
let email = CStr::from_ptr(email).to_str().unwrap();
let password = CStr::from_ptr(password).to_str().unwrap();
let master_key = derive_master_key(password, email);
let master_password_hash = master_key
.derive_master_key_hash(password.as_bytes(), HashPurpose::ServerAuthorization)
.unwrap();
let (user_key, encrypted_user_key) = master_key.make_user_key().unwrap();
let keypair = keypair(&user_key.0);
let json = serde_json::json!({
"masterPasswordHash": master_password_hash,
"key": user_key.0.to_base64(),
"encryptedUserKey": encrypted_user_key.to_string(),
"publicKey": keypair.public.to_string(),
"privateKey": keypair.private.to_string(),
})
.to_string();
let result = CString::new(json).unwrap();
result.into_raw()
}
fn derive_master_key(email: &str, password: &str) -> MasterKey {
let mut hash = pbkdf2(password.as_bytes(), email.as_bytes(), 5000);
let hash = hash.as_mut_slice();
hash.try_into().unwrap()
}
fn keypair(key: &SymmetricCryptoKey) -> RsaKeyPair {
const RSA_PRIVATE_KEY: &str = "-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCXRVrCX+2hfOQS
8HzYUS2oc/jGVTZpv+/Ryuoh9d8ihYX9dd0cYh2tl6KWdFc88lPUH11Oxqy20Rk2
e5r/RF6T9yM0Me3NPnaKt+hlhLtfoc0h86LnhD56A9FDUfuI0dVnPcrwNv0YJIo9
4LwxtbqBULNvXl6wJ7WAbODrCQy5ZgMVg+iH+gGpwiqsZqHt+KuoHWcN53MSPDfa
F4/YMB99U3TziJMOOJask1TEEnakMPln11PczNDazT17DXIxYrbPfutPdh6sLs6A
QOajdZijfEvepgnOe7cQ7aeatiOJFrjTApKPGxOVRzEMX4XS4xbyhH0QxQeB6l16
l8C0uxIBAgMBAAECggEASaWfeVDA3cVzOPFSpvJm20OTE+R6uGOU+7vh36TX/POq
92qBuwbd0h0oMD32FxsXywd2IxtBDUSiFM9699qufTVuM0Q3tZw6lHDTOVG08+tP
dr8qSbMtw7PGFxN79fHLBxejjO4IrM9lapjWpxEF+11x7r+wM+0xRZQ8sNFYG46a
PfIaty4BGbL0I2DQ2y8I57iBCAy69eht59NLMm27fRWGJIWCuBIjlpfzET1j2HLX
UIh5bTBNzqaN039WH49HczGE3mQKVEJZc/efk3HaVd0a1Sjzyn0QY+N1jtZN3jTR
buDWA1AknkX1LX/0tUhuS3/7C3ejHxjw4Dk1ZLo5/QKBgQDIWvqFn0+IKRSu6Ua2
hDsufIHHUNLelbfLUMmFthxabcUn4zlvIscJO00Tq/ezopSRRvbGiqnxjv/mYxuc
vOUBeZtlus0Q9RTACBtw9TGoNTmQbEunJ2FOSlqbQxkBBAjgGEppRPt30iGj/VjA
hCATq2MYOa/X4dVR51BqQAFIEwKBgQDBSIfTFKC/hDk6FKZlgwvupWYJyU9Rkyfs
tPErZFmzoKhPkQ3YORo2oeAYmVUbS9I2iIYpYpYQJHX8jMuCbCz4ONxTCuSIXYQY
UcUq4PglCKp31xBAE6TN8SvhfME9/MvuDssnQinAHuF0GDAhF646T3LLS1not6Vs
zv7brwSoGwKBgQC88v/8cGfi80ssQZeMnVvq1UTXIeQcQnoY5lGHJl3K8mbS3TnX
E6c9j417Fdz+rj8KWzBzwWXQB5pSPflWcdZO886Xu/mVGmy9RWgLuVFhXwCwsVEP
jNX5ramRb0/vY0yzenUCninBsIxFSbIfrPtLUYCc4hpxr+sr2Mg/y6jpvQKBgBez
MRRs3xkcuXepuI2R+BCXL1/b02IJTUf1F+1eLLGd7YV0H+J3fgNc7gGWK51hOrF9
JBZHBGeOUPlaukmPwiPdtQZpu4QNE3l37VlIpKTF30E6mb+BqR+nht3rUjarnMXg
AoEZ18y6/KIjpSMpqC92Nnk/EBM9EYe6Cf4eA9ApAoGAeqEUg46UTlJySkBKURGp
Is3v1kkf5I0X8DnOhwb+HPxNaiEdmO7ckm8+tPVgppLcG0+tMdLjigFQiDUQk2y3
WjyxP5ZvXu7U96jaJRI8PFMoE06WeVYcdIzrID2HvqH+w0UQJFrLJ/0Mn4stFAEz
XKZBokBGnjFnTnKcs7nv/O8=
-----END PRIVATE KEY-----";
let private_key = AsymmetricCryptoKey::from_pem(RSA_PRIVATE_KEY).unwrap();
let public_key = private_key.to_public_key().to_der().unwrap();
let b64 = STANDARD.encode(public_key);
let p = private_key.to_der().unwrap();
RsaKeyPair {
private: p.encrypt_with_key(key).unwrap(),
public: b64,
}
}
#[no_mangle]
pub unsafe extern "C" fn generate_organization_keys() -> *const c_char {
let key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
let key = UserKey::new(key);
let keypair = key.make_key_pair().expect("Failed to generate key pair");
let json = serde_json::json!({
"key": key.0.to_base64(),
"publicKey": keypair.public.to_string(),
"privateKey": keypair.private.to_string(),
})
.to_string();
let result = CString::new(json).unwrap();
result.into_raw()
}
#[no_mangle]
pub unsafe extern "C" fn generate_user_organization_key(
user_public_key: *const c_char,
organization_key: *const c_char,
) -> *const c_char {
let user_public_key = CStr::from_ptr(user_public_key).to_str().unwrap().to_owned();
let organization_key = CStr::from_ptr(organization_key)
.to_str()
.unwrap()
.to_owned();
let user_public_key = STANDARD.decode(user_public_key).unwrap();
let organization_key = STANDARD.decode(organization_key).unwrap();
let encapsulation_key =
AsymmetricPublicCryptoKey::from_der(&SpkiPublicKeyBytes::from(user_public_key)).unwrap();
let encrypted_key = UnsignedSharedKey::encapsulate_key_unsigned(
&SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(organization_key)).unwrap(),
&encapsulation_key,
)
.unwrap();
let result = CString::new(encrypted_key.to_string()).unwrap();
result.into_raw()
}
/// # Safety
///
/// The `str` pointer must be a valid pointer previously returned by `CString::into_raw`
/// and must not have already been freed. After calling this function, the pointer must not be used again.
#[no_mangle]
pub unsafe extern "C" fn free_c_string(str: *mut c_char) {
unsafe {
drop(CString::from_raw(str));
}
}

View File

@@ -0,0 +1,56 @@
internal static unsafe partial class NativeMethods
{
// https://docs.microsoft.com/en-us/dotnet/standard/native-interop/cross-platform
// Library path will search
// win => __DllName, __DllName.dll
// linux, osx => __DllName.so, __DllName.dylib
static NativeMethods()
{
NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, DllImportResolver);
}
static IntPtr DllImportResolver(string libraryName, Assembly assembly, DllImportSearchPath? searchPath)
{
if (libraryName == __DllName)
{
var path = "runtimes/";
var extension = "";
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
path += "win-";
extension = ".dll";
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
{
path += "osx-";
extension = ".dylib";
}
else
{
path += "linux-";
extension = ".so";
}
if (RuntimeInformation.ProcessArchitecture == Architecture.X86)
{
path += "x86";
}
else if (RuntimeInformation.ProcessArchitecture == Architecture.X64)
{
path += "x64";
}
else if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64)
{
path += "arm64";
}
path += "/native/" + __DllName + extension;
return NativeLibrary.Load(Path.Combine(AppContext.BaseDirectory, path), assembly, searchPath);
}
return IntPtr.Zero;
}
}

View File

@@ -41,4 +41,18 @@ public static class OrgnaizationExtensions
Status = OrganizationUserStatusType.Confirmed
};
}
public static OrganizationUser CreateSdkOrganizationUser(this Organization organization, User user)
{
return new OrganizationUser
{
Id = Guid.NewGuid(),
OrganizationId = organization.Id,
UserId = user.Id,
Key = "4.rY01mZFXHOsBAg5Fq4gyXuklWfm6mQASm42DJpx05a+e2mmp+P5W6r54WU2hlREX0uoTxyP91bKKwickSPdCQQ58J45LXHdr9t2uzOYyjVzpzebFcdMw1eElR9W2DW8wEk9+mvtWvKwu7yTebzND+46y1nRMoFydi5zPVLSlJEf81qZZ4Uh1UUMLwXz+NRWfixnGXgq2wRq1bH0n3mqDhayiG4LJKgGdDjWXC8W8MMXDYx24SIJrJu9KiNEMprJE+XVF9nQVNijNAjlWBqkDpsfaWTUfeVLRLctfAqW1blsmIv4RQ91PupYJZDNc8nO9ZTF3TEVM+2KHoxzDJrLs2Q==",
Type = OrganizationUserType.Admin,
Status = OrganizationUserStatusType.Confirmed
};
}
}

View File

@@ -1,6 +1,8 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
using Bit.Infrastructure.EntityFramework.Models;
using Bit.RustSDK;
using Microsoft.AspNetCore.Identity;
namespace Bit.Seeder.Factories;
@@ -66,27 +68,56 @@ public class UserSeeder(Guid mangleId)
{
var mangleMap = new Dictionary<string, string?>
{
{expectedUserData.Email, MangleEmail(expectedUserData.Email) },
{expectedUserData.Id.ToString(), user.Id.ToString() },
{expectedUserData.Kdf.ToString(), user.Kdf.ToString() },
{expectedUserData.KdfIterations.ToString(), user.KdfIterations.ToString() }
{ expectedUserData.Email, MangleEmail(expectedUserData.Email) },
{ expectedUserData.Id.ToString(), user.Id.ToString() },
{ expectedUserData.Kdf.ToString(), user.Kdf.ToString() },
{ expectedUserData.KdfIterations.ToString(), user.KdfIterations.ToString() }
};
if (expectedUserData.Key != null)
{
mangleMap[expectedUserData.Key] = user.Key;
}
if (expectedUserData.PublicKey != null)
{
mangleMap[expectedUserData.PublicKey] = user.PublicKey;
}
if (expectedUserData.PrivateKey != null)
{
mangleMap[expectedUserData.PrivateKey] = user.PrivateKey;
}
if (expectedUserData.ApiKey != null)
{
mangleMap[expectedUserData.ApiKey] = user.ApiKey;
}
return mangleMap;
}
public static (User user, string userKey) CreateSdkUser(IPasswordHasher<Bit.Core.Entities.User> passwordHasher, string email)
{
var nativeService = RustSdkServiceFactory.CreateSingleton();
var keys = nativeService.GenerateUserKeys(email, "asdfasdfasdf");
var user = new User
{
Id = Guid.NewGuid(),
Email = email,
MasterPassword = null,
SecurityStamp = "4830e359-e150-4eae-be2a-996c81c5e609",
Key = keys.EncryptedUserKey,
PublicKey = keys.PublicKey,
PrivateKey = keys.PrivateKey,
ApiKey = "7gp59kKHt9kMlks0BuNC4IjNXYkljR",
Kdf = KdfType.PBKDF2_SHA256,
KdfIterations = 5_000,
};
user.MasterPassword = passwordHasher.HashPassword(user, keys.MasterPasswordHash);
return (user, keys.Key);
}
}

View File

@@ -20,6 +20,7 @@
<ProjectReference Include="..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\..\src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj" />
<ProjectReference Include="..\..\src\SharedWeb\SharedWeb.csproj" />
<ProjectReference Include="..\RustSdk\RustSdk.csproj" />
</ItemGroup>
<ItemGroup>