1
0
mirror of https://github.com/bitwarden/server synced 2026-02-09 05:00:32 +00:00

Merge branch 'main' into auth/pm-27084/register-accepts-new-data-types-repush

This commit is contained in:
Patrick-Pimentel-Bitwarden
2026-01-15 16:26:56 -05:00
committed by GitHub
180 changed files with 18623 additions and 2358 deletions

View File

@@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
dotnet_naming_symbols.any_async_methods.required_modifiers = async
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_suffix = Async
dotnet_naming_style.end_in_async.capitalization = pascal_case
dotnet_naming_style.end_in_async.word_separator =
dotnet_naming_style.end_in_async.word_separator =
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
dotnet_diagnostic.CS0618.severity = suggestion
@@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
dotnet_diagnostic.IDE0005.severity = warning
# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304
dotnet_diagnostic.CA1304.severity = warning
# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305
dotnet_diagnostic.CA1305.severity = warning
# CSharp code style settings:
[*.cs]
# Prefer "var" everywhere

84
.vscode/launch.json vendored
View File

@@ -69,6 +69,28 @@
"preLaunchTask": "buildFullServer",
"stopAll": true
},
{
"name": "Full Server with Seeder API",
"configurations": [
"run-Admin",
"run-API",
"run-Events",
"run-EventsProcessor",
"run-Identity",
"run-Sso",
"run-Icons",
"run-Billing",
"run-Notifications",
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 6
},
"preLaunchTask": "buildFullServerWithSeederApi",
"stopAll": true
},
{
"name": "Self Host: Bit",
"configurations": [
@@ -204,6 +226,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API",
"configurations": [
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "cloud",
},
"preLaunchTask": "buildSeederAPI",
},
{
"name": "Admin Self Host",
"configurations": [
@@ -270,6 +303,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API Self Host",
"configurations": [
"run-SeederAPI-SelfHost"
],
"presentation": {
"hidden": false,
"group": "self-host",
},
"preLaunchTask": "buildSeederAPI",
}
],
"configurations": [
// Configurations represent run-only scenarios so that they can be used in multiple compounds
@@ -311,6 +355,25 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Billing",
"presentation": {
@@ -488,6 +551,27 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI-SelfHost",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5048",
"developSelfHosted": "true",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Admin-SelfHost",
"presentation": {

69
.vscode/tasks.json vendored
View File

@@ -43,6 +43,21 @@
"label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
"buildEventsProcessor",
"buildIdentity",
"buildSso",
"buildIcons",
"buildBilling",
"buildNotifications"
],
},
{
"label": "buildFullServerWithSeederApi",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
@@ -52,6 +67,7 @@
"buildIcons",
"buildBilling",
"buildNotifications",
"buildSeederAPI"
],
},
{
@@ -89,6 +105,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -102,6 +121,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -115,6 +137,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -128,6 +153,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -141,6 +169,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -154,6 +185,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -167,6 +201,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -180,6 +217,29 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildSeederAPI",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -197,6 +257,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -214,6 +277,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -224,6 +290,9 @@
"label": "test",
"type": "shell",
"command": "dotnet test",
"options": {
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "test",
"isDefault": true

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.12.2</Version>
<Version>2026.1.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@@ -13,21 +13,21 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<XUnitVersion>2.6.6</XUnitVersion>
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
</Project>

View File

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

View File

@@ -44,6 +44,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -0,0 +1,102 @@
using Bit.Sso.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Sso.IdentityServer;
/// <summary>
/// Distributed cache-backed persisted grant store for short-lived grants.
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
/// and fall back to in-memory caching if Redis is not configured.
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
/// </summary>
/// <remarks>
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
/// mechanism (cache, database).
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
/// </remarks>
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
{
private readonly IFusionCache _cache;
public DistributedCachePersistedGrantStore(
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
{
_cache = cache;
}
public async Task<PersistedGrant?> GetAsync(string key)
{
var result = await _cache.TryGetAsync<PersistedGrant>(key);
if (!result.HasValue)
{
return null;
}
var grant = result.Value;
// Check if grant has expired - remove expired grants from cache
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
{
await RemoveAsync(key);
return null;
}
return grant;
}
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
{
// Cache stores are key-value based and don't support querying by filter criteria.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
}
public Task RemoveAllAsync(PersistedGrantFilter filter)
{
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
// authentication cookies and performs federated logout with external IdPs. It does not invoke
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
// Cache stores are key-value based and don't support bulk deletion by filter.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(0);
}
public async Task RemoveAsync(string key)
{
await _cache.RemoveAsync(key);
}
public async Task StoreAsync(PersistedGrant grant)
{
// Calculate TTL based on grant expiration
var duration = grant.Expiration.HasValue
? grant.Expiration.Value - DateTime.UtcNow
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
// Ensure positive duration
if (duration <= TimeSpan.Zero)
{
return;
}
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
// give us a consistent cache key prefix for these grants.
await _cache.SetAsync(
grant.Key,
grant,
new FusionCacheEntryOptions { Duration = duration });
}
}

View File

@@ -41,6 +41,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -0,0 +1,10 @@
namespace Bit.Sso.Utilities;
public static class PersistedGrantsDistributedCacheConstants
{
/// <summary>
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
/// well as the cache key/namespace for grant storage.
/// </summary>
public const string CacheKey = "sso-grants";
}

View File

@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
using Bit.Sso.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Sustainsys.Saml2.AspNetCore2;
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
})
.AddIdentityServerCertificate(env, globalSettings);
// PM-23572
// Register named FusionCache for SSO authorization code grants.
// Provides separation of concerns and automatic Redis/in-memory negotiation
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
// and is separate from this keyed service, which only serves grant negotiation.
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
// Store authorization codes in distributed cache for horizontal scaling
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
return identityServerBuilder;
}
}

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -749,9 +749,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -792,9 +792,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -813,11 +813,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -834,9 +834,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -988,9 +988,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1022,9 +1022,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1418,13 +1418,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1541,9 +1545,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1874,9 +1878,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2109,9 +2113,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2165,9 +2169,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2217,9 +2221,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2232,21 +2236,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -0,0 +1,257 @@
using Bit.Sso.IdentityServer;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using NSubstitute;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.SSO.Test.IdentityServer;
public class DistributedCachePersistedGrantStoreTests
{
private readonly IFusionCache _cache;
private readonly DistributedCachePersistedGrantStore _sut;
public DistributedCachePersistedGrantStoreTests()
{
_cache = Substitute.For<IFusionCache>();
_sut = new DistributedCachePersistedGrantStore(_cache);
}
[Fact]
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
{
// Arrange
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"test-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
opts.Duration <= TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"no-expiry-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
// Act
await _sut.StoreAsync(expiredGrant);
// Assert
await _cache.DidNotReceive().SetAsync(
Arg.Any<string>(),
Arg.Any<PersistedGrant>(),
Arg.Any<FusionCacheEntryOptions?>());
}
[Fact]
public async Task StoreAsync_EnablesDistributedCache()
{
// Arrange
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"distributed-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.SkipDistributedCache == false &&
opts.SkipDistributedCacheReadWhenStale == false));
}
[Fact]
public async Task GetAsync_WithValidGrant_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
_cache.TryGetAsync<PersistedGrant>("valid-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("valid-key");
// Assert
Assert.NotNull(result);
Assert.Equal("valid-key", result.Key);
Assert.Equal("authorization_code", result.Type);
Assert.Equal("test-subject", result.SubjectId);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
{
// Arrange
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
.Returns(MaybeValue<PersistedGrant>.None);
// Act
var result = await _sut.GetAsync("nonexistent-key");
// Assert
Assert.Null(result);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
_cache.TryGetAsync<PersistedGrant>("expired-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
// Act
var result = await _sut.GetAsync("expired-key");
// Assert
Assert.Null(result);
await _cache.Received(1).RemoveAsync("expired-key");
}
[Fact]
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("no-expiry-key");
// Assert
Assert.NotNull(result);
Assert.Equal("no-expiry-key", result.Key);
Assert.Null(result.Expiration);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task RemoveAsync_RemovesGrantFromCache()
{
// Act
await _sut.RemoveAsync("remove-key");
// Assert
await _cache.Received(1).RemoveAsync("remove-key");
}
[Fact]
public async Task GetAllAsync_ReturnsEmptyCollection()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
SessionId = "test-session",
ClientId = "test-client",
Type = "authorization_code"
};
// Act
var result = await _sut.GetAllAsync(filter);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task RemoveAllAsync_CompletesWithoutError()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
ClientId = "test-client"
};
// Act & Assert - should not throw
await _sut.RemoveAllAsync(filter);
// Verify no cache operations were performed
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task StoreAsync_PreservesAllGrantProperties()
{
// Arrange
var grant = new PersistedGrant
{
Key = "full-grant-key",
Type = "authorization_code",
SubjectId = "user-123",
SessionId = "session-456",
ClientId = "client-789",
Description = "Test grant",
CreationTime = DateTime.UtcNow.AddMinutes(-1),
Expiration = DateTime.UtcNow.AddMinutes(5),
ConsumedTime = null,
Data = "{\"test\":\"data\"}"
};
PersistedGrant? capturedGrant = null;
await _cache.SetAsync(
Arg.Any<string>(),
Arg.Do<PersistedGrant>(g => capturedGrant = g),
Arg.Any<FusionCacheEntryOptions?>());
// Act
await _sut.StoreAsync(grant);
// Assert
Assert.NotNull(capturedGrant);
Assert.Equal(grant.Key, capturedGrant.Key);
Assert.Equal(grant.Type, capturedGrant.Type);
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
Assert.Equal(grant.Description, capturedGrant.Description);
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
Assert.Equal(grant.Data, capturedGrant.Data);
}
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
{
return new PersistedGrant
{
Key = key,
Type = "authorization_code",
SubjectId = "test-subject",
ClientId = "test-client",
CreationTime = DateTime.UtcNow,
Expiration = expiration,
Data = "{\"test\":\"data\"}"
};
}
}

View File

@@ -1,35 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

23
dev/setup_secrets.ps1 Normal file → Executable file
View File

@@ -2,7 +2,7 @@
# Helper script for applying the same user secrets to each project
param (
[switch]$clear,
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
$cmdArgs
)
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
}
$projects = @{
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
}
foreach ($key in $projects.keys) {

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />

View File

@@ -1,5 +1,5 @@
using Bit.Core.Utilities;
using Microsoft.Data.SqlClient;
using System.Data.Common;
using Bit.Core.Utilities;
namespace Bit.Admin.HostedServices;
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
break;
}
catch (SqlException e)
catch (DbException e)
{
if (i >= maxMigrationAttempts)
{
@@ -40,7 +40,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
else
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
await Task.Delay(20000, cancellationToken);
}
}

View File

@@ -65,6 +65,7 @@ public class Startup
default:
break;
}
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -750,9 +750,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -793,9 +793,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -814,11 +814,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -835,9 +835,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -989,9 +989,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1023,9 +1023,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1419,13 +1419,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1542,9 +1546,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1875,9 +1879,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2110,9 +2114,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2174,9 +2178,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2226,9 +2230,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2241,21 +2245,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -665,11 +665,6 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
}
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{

View File

@@ -4,6 +4,8 @@
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<ANCMPreConfiguredForIIS>true</ANCMPreConfiguredForIIS>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -36,7 +36,7 @@ public class EmergencyAccessUpdateRequestModel
existingEmergencyAccess.KeyEncrypted = KeyEncrypted;
}
existingEmergencyAccess.Type = Type;
existingEmergencyAccess.WaitTimeDays = WaitTimeDays;
existingEmergencyAccess.WaitTimeDays = (short)WaitTimeDays;
return existingEmergencyAccess;
}
}

View File

@@ -85,6 +85,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -5,9 +5,11 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Api.Utilities;
using Bit.Core;
using Bit.Core.Auth.Identity;
using Bit.Core.Auth.UserFeatures.SendAccess;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
@@ -22,7 +24,6 @@ using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Tools.Controllers;
[Route("sends")]
[Authorize("Application")]
public class SendsController : Controller
{
private readonly ISendRepository _sendRepository;
@@ -31,11 +32,10 @@ public class SendsController : Controller
private readonly ISendFileStorageService _sendFileStorageService;
private readonly IAnonymousSendCommand _anonymousSendCommand;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
private readonly ISendOwnerQuery _sendOwnerQuery;
private readonly ILogger<SendsController> _logger;
private readonly GlobalSettings _globalSettings;
private readonly IFeatureService _featureService;
private readonly IPushNotificationService _pushNotificationService;
public SendsController(
ISendRepository sendRepository,
@@ -46,7 +46,8 @@ public class SendsController : Controller
ISendOwnerQuery sendOwnerQuery,
ISendFileStorageService sendFileStorageService,
ILogger<SendsController> logger,
GlobalSettings globalSettings)
IFeatureService featureService,
IPushNotificationService pushNotificationService)
{
_sendRepository = sendRepository;
_userService = userService;
@@ -56,10 +57,12 @@ public class SendsController : Controller
_sendOwnerQuery = sendOwnerQuery;
_sendFileStorageService = sendFileStorageService;
_logger = logger;
_globalSettings = globalSettings;
_featureService = featureService;
_pushNotificationService = pushNotificationService;
}
#region Anonymous endpoints
[AllowAnonymous]
[HttpPost("access/{id}")]
public async Task<IActionResult> Access(string id, [FromBody] SendAccessRequestModel model)
@@ -73,21 +76,32 @@ public class SendsController : Controller
var guid = new Guid(CoreHelpers.Base64UrlDecode(id));
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}
var sendAuthResult =
await _sendAuthorizationService.AccessAsync(send, model.Password);
if (sendAuthResult.Equals(SendAccessResult.PasswordRequired))
{
return new UnauthorizedResult();
}
if (sendAuthResult.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (sendAuthResult.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
@@ -99,6 +113,7 @@ public class SendsController : Controller
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
return new ObjectResult(sendResponse);
}
@@ -122,6 +137,13 @@ public class SendsController : Controller
throw new BadRequestException("Could not locate send");
}
/* This guard can be removed once feature flag is retired*/
var sendEmailOtpEnabled = _featureService.IsEnabled(FeatureFlagKeys.SendEmailOTP);
if (sendEmailOtpEnabled && send.AuthType == AuthType.Email && send.Emails is not null)
{
return new UnauthorizedResult();
}
var (url, result) = await _anonymousSendCommand.GetSendFileDownloadUrlAsync(send, fileId,
model.Password);
@@ -129,21 +151,19 @@ public class SendsController : Controller
{
return new UnauthorizedResult();
}
if (result.Equals(SendAccessResult.PasswordInvalid))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid password.");
}
if (result.Equals(SendAccessResult.Denied))
{
throw new NotFoundException();
}
return new ObjectResult(new SendFileDownloadDataResponseModel()
{
Id = fileId,
Url = url,
});
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url, });
}
[AllowAnonymous]
@@ -157,7 +177,8 @@ public class SendsController : Controller
{
try
{
var blobName = eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var blobName =
eventGridEvent.Subject.Split($"{AzureSendFileStorageService.FilesContainerName}/blobs/")[1];
var sendId = AzureSendFileStorageService.SendIdFromBlobName(blobName);
var send = await _sendRepository.GetByIdAsync(new Guid(sendId));
if (send == null)
@@ -166,6 +187,7 @@ public class SendsController : Controller
{
await azureSendFileStorageService.DeleteBlobAsync(blobName);
}
return;
}
@@ -173,7 +195,8 @@ public class SendsController : Controller
}
catch (Exception e)
{
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}", JsonSerializer.Serialize(eventGridEvent));
_logger.LogError(e, "Uncaught exception occurred while handling event grid event: {Event}",
JsonSerializer.Serialize(eventGridEvent));
return;
}
}
@@ -185,6 +208,7 @@ public class SendsController : Controller
#region Non-anonymous endpoints
[Authorize(Policies.Application)]
[HttpGet("{id}")]
public async Task<SendResponseModel> Get(string id)
{
@@ -193,6 +217,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpGet("")]
public async Task<ListResponseModel<SendResponseModel>> GetAll()
{
@@ -203,6 +228,67 @@ public class SendsController : Controller
return result;
}
[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/")]
public async Task<IActionResult> AccessUsingAuth()
{
var guid = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(guid);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var sendResponse = new SendAccessResponseModel(send);
if (send.UserId.HasValue && !send.HideEmail.GetValueOrDefault())
{
var creator = await _userService.GetUserByIdAsync(send.UserId.Value);
sendResponse.CreatorIdentifier = creator.Email;
}
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(sendResponse);
}
[Authorize(Policy = Policies.Send)]
// [RequireFeature(FeatureFlagKeys.SendEmailOTP)] /* Uncomment once client fallback re-try logic is added */
[HttpPost("access/file/{fileId}")]
public async Task<IActionResult> GetSendFileDownloadDataUsingAuth(string fileId)
{
var sendId = User.GetSendId();
var send = await _sendRepository.GetByIdAsync(sendId);
if (send == null)
{
throw new BadRequestException("Could not locate send");
}
if (send.MaxAccessCount.GetValueOrDefault(int.MaxValue) <= send.AccessCount ||
send.ExpirationDate.GetValueOrDefault(DateTime.MaxValue) < DateTime.UtcNow || send.Disabled ||
send.DeletionDate < DateTime.UtcNow)
{
throw new NotFoundException();
}
var url = await _sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId);
send.AccessCount++;
await _sendRepository.ReplaceAsync(send);
await _pushNotificationService.PushSyncSendUpdateAsync(send);
return new ObjectResult(new SendFileDownloadDataResponseModel() { Id = fileId, Url = url });
}
[Authorize(Policies.Application)]
[HttpPost("")]
public async Task<SendResponseModel> Post([FromBody] SendRequestModel model)
{
@@ -213,6 +299,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPost("file/v2")]
public async Task<SendFileUploadDataResponseModel> PostFile([FromBody] SendRequestModel model)
{
@@ -243,6 +330,7 @@ public class SendsController : Controller
};
}
[Authorize(Policies.Application)]
[HttpGet("{id}/file/{fileId}")]
public async Task<SendFileUploadDataResponseModel> RenewFileUpload(string id, string fileId)
{
@@ -267,6 +355,7 @@ public class SendsController : Controller
};
}
[Authorize(Policies.Application)]
[HttpPost("{id}/file/{fileId}")]
[SelfHosted(SelfHostedOnly = true)]
[RequestSizeLimit(Constants.FileSize501mb)]
@@ -283,12 +372,14 @@ public class SendsController : Controller
{
throw new BadRequestException("Could not locate send");
}
await Request.GetFileAsync(async (stream) =>
{
await _nonAnonymousSendCommand.UploadFileToExistingSendAsync(stream, send);
});
}
[Authorize(Policies.Application)]
[HttpPut("{id}")]
public async Task<SendResponseModel> Put(string id, [FromBody] SendRequestModel model)
{
@@ -304,6 +395,7 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-password")]
public async Task<SendResponseModel> PutRemovePassword(string id)
{
@@ -322,6 +414,28 @@ public class SendsController : Controller
return new SendResponseModel(send);
}
// Removes ALL authentication (email or password) if any is present
[Authorize(Policies.Application)]
[HttpPut("{id}/remove-auth")]
public async Task<SendResponseModel> PutRemoveAuth(string id)
{
var userId = _userService.GetProperUserId(User) ?? throw new InvalidOperationException("User ID not found");
var send = await _sendRepository.GetByIdAsync(new Guid(id));
if (send == null || send.UserId != userId)
{
throw new NotFoundException();
}
// This endpoint exists because PUT preserves existing Password/Emails when not provided.
// This allows clients to update other fields without re-submitting sensitive auth data.
send.Password = null;
send.Emails = null;
send.AuthType = AuthType.None;
await _nonAnonymousSendCommand.SaveSendAsync(send);
return new SendResponseModel(send);
}
[Authorize(Policies.Application)]
[HttpDelete("{id}")]
public async Task Delete(string id)
{

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Billing</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Label="Server SDK settings">

View File

@@ -48,6 +48,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -128,6 +128,7 @@ public class SelfHostedOrganizationDetails : Organization
UseApi = UseApi,
UseResetPassword = UseResetPassword,
UseSecretsManager = UseSecretsManager,
UsePasswordManager = UsePasswordManager,
SelfHost = SelfHost,
UsersGetPremium = UsersGetPremium,
UseCustomPermissions = UseCustomPermissions,
@@ -156,6 +157,8 @@ public class SelfHostedOrganizationDetails : Organization
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UseDisableSmAdsForUsers = UseDisableSmAdsForUsers,
UsePhishingBlocker = UsePhishingBlocker,
UseOrganizationDomains = UseOrganizationDomains,
UseAutomaticUserConfirmation = UseAutomaticUserConfirmation,
};
}
}

View File

@@ -7,6 +7,4 @@ public interface IRevokeOrganizationUserCommand
{
Task RevokeUserAsync(OrganizationUser organizationUser, Guid? revokingUserId);
Task RevokeUserAsync(OrganizationUser organizationUser, EventSystemUser systemUser);
Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId);
}

View File

@@ -68,68 +68,4 @@ public class RevokeOrganizationUserCommand(
await organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
}
public async Task<List<Tuple<OrganizationUser, string>>> RevokeUsersAsync(Guid organizationId,
IEnumerable<Guid> organizationUserIds, Guid? revokingUserId)
{
var orgUsers = await organizationUserRepository.GetManyAsync(organizationUserIds);
var filteredUsers = orgUsers.Where(u => u.OrganizationId == organizationId)
.ToList();
if (!filteredUsers.Any())
{
throw new BadRequestException("Users invalid.");
}
if (!await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(organizationId, organizationUserIds))
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
}
var deletingUserIsOwner = false;
if (revokingUserId.HasValue)
{
deletingUserIsOwner = await currentContext.OrganizationOwner(organizationId);
}
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var organizationUser in filteredUsers)
{
try
{
if (organizationUser.Status == OrganizationUserStatusType.Revoked)
{
throw new BadRequestException("Already revoked.");
}
if (revokingUserId.HasValue && organizationUser.UserId == revokingUserId)
{
throw new BadRequestException("You cannot revoke yourself.");
}
if (organizationUser.Type == OrganizationUserType.Owner && revokingUserId.HasValue &&
!deletingUserIsOwner)
{
throw new BadRequestException("Only owners can revoke other owners.");
}
await organizationUserRepository.RevokeAsync(organizationUser.Id);
organizationUser.Status = OrganizationUserStatusType.Revoked;
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_Revoked);
if (organizationUser.UserId.HasValue)
{
await pushNotificationService.PushSyncOrgKeysAsync(organizationUser.UserId.Value);
}
result.Add(Tuple.Create(organizationUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(organizationUser, e.Message));
}
}
return result;
}
}

View File

@@ -74,8 +74,12 @@ public class AutomaticUserConfirmationPolicyEventHandler(
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
organizationUsers.Select(ou => ou.UserId!.Value)))
var userIds = organizationUsers.Where(
u => u.UserId is not null &&
u.Status != OrganizationUserStatusType.Invited)
.Select(u => u.UserId!.Value);
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(userIds))
.Any(uo => uo.OrganizationId != organizationId
&& uo.Status != OrganizationUserStatusType.Invited);

View File

@@ -18,7 +18,7 @@ public class EmergencyAccess : ITableObject<Guid>
public string KeyEncrypted { get; set; }
public EmergencyAccessType Type { get; set; }
public EmergencyAccessStatusType Status { get; set; }
public int WaitTimeDays { get; set; }
public short WaitTimeDays { get; set; }
public DateTime? RecoveryInitiatedDate { get; set; }
public DateTime? LastNotificationDate { get; set; }
public DateTime CreationDate { get; set; } = DateTime.UtcNow;

View File

@@ -79,7 +79,7 @@ public class EmergencyAccessService : IEmergencyAccessService
Email = emergencyContactEmail.ToLowerInvariant(),
Status = EmergencyAccessStatusType.Invited,
Type = accessType,
WaitTimeDays = waitTime,
WaitTimeDays = (short)waitTime,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};

View File

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

View File

@@ -1,9 +1,11 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
@@ -46,6 +48,57 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
}
var claimsPrincipal = _licensingService.GetClaimsPrincipalFromLicense(license);
// If the license has a Token (claims-based), extract all properties from claims BEFORE validation
// This ensures that CanUseLicense validation has access to the correct values from claims
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
if (claimsPrincipal != null)
{
license.Name = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Name);
license.BillingEmail = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BillingEmail);
license.BusinessName = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.BusinessName);
license.PlanType = claimsPrincipal.GetValue<PlanType>(OrganizationLicenseConstants.PlanType);
license.Seats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.Seats);
license.MaxCollections = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxCollections);
license.UsePolicies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePolicies);
license.UseSso = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSso);
license.UseKeyConnector = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseKeyConnector);
license.UseScim = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseScim);
license.UseGroups = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseGroups);
license.UseDirectory = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDirectory);
license.UseEvents = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseEvents);
license.UseTotp = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseTotp);
license.Use2fa = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Use2fa);
license.UseApi = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseApi);
license.UseResetPassword = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseResetPassword);
license.Plan = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.Plan);
license.SelfHost = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.SelfHost);
license.UsersGetPremium = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsersGetPremium);
license.UseCustomPermissions = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseCustomPermissions);
license.Enabled = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Enabled);
license.Expires = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Expires);
license.LicenseKey = claimsPrincipal.GetValue<string>(OrganizationLicenseConstants.LicenseKey);
license.UsePasswordManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePasswordManager);
license.UseSecretsManager = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseSecretsManager);
license.SmSeats = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmSeats);
license.SmServiceAccounts = claimsPrincipal.GetValue<int?>(OrganizationLicenseConstants.SmServiceAccounts);
license.UseRiskInsights = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseRiskInsights);
license.UseOrganizationDomains = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseOrganizationDomains);
license.UseAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAdminSponsoredFamilies);
license.UseAutomaticUserConfirmation = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation);
license.UseDisableSmAdsForUsers = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UseDisableSmAdsForUsers);
license.UsePhishingBlocker = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.UsePhishingBlocker);
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(OrganizationLicenseConstants.MaxStorageGb);
license.InstallationId = claimsPrincipal.GetValue<Guid>(OrganizationLicenseConstants.InstallationId);
license.LicenseType = claimsPrincipal.GetValue<LicenseType>(OrganizationLicenseConstants.LicenseType);
license.Issued = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Issued);
license.Refresh = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.Refresh);
license.ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
license.Trial = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.Trial);
license.LimitCollectionCreationDeletion = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.LimitCollectionCreationDeletion);
license.AllowAdminAccessToAllCollectionItems = claimsPrincipal.GetValue<bool>(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems);
}
var canUse = license.CanUse(_globalSettings, _licensingService, claimsPrincipal, out var exception) &&
selfHostedOrganization.CanUseLicense(license, out exception);
@@ -54,12 +107,6 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
throw new BadRequestException(exception);
}
var useAutomaticUserConfirmation = claimsPrincipal?
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
await WriteLicenseFileAsync(selfHostedOrganization, license);
await UpdateOrganizationAsync(selfHostedOrganization, license);
}

View File

@@ -361,7 +361,8 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
var invoice = await stripeAdapter.UpdateInvoiceAsync(subscription.LatestInvoiceId, new InvoiceUpdateOptions
{
AutoAdvance = false
AutoAdvance = false,
Expand = ["customer"]
});
await braintreeService.PayInvoice(new UserId(userId), invoice);

View File

@@ -142,8 +142,7 @@ public static class FeatureFlagKeys
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
public const string PremiumAccessQuery = "pm-21411-premium-access-query";
public const string PremiumAccessQuery = "pm-29495-refactor-premium-interface";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
@@ -165,6 +164,7 @@ public static class FeatureFlagKeys
public const string MarketingInitiatedPremiumFlow = "pm-26140-marketing-initiated-premium-flow";
public const string RedirectOnSsoRequired = "pm-1632-redirect-on-sso-required";
public const string PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin";
public const string PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password";
/* Autofill Team */
public const string SSHAgent = "ssh-agent";
@@ -202,6 +202,7 @@ public static class FeatureFlagKeys
public const string V2RegistrationTDEJIT = "pm-27279-v2-registration-tde-jit";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
public const string EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration";
public const string SdkKeyRotation = "pm-30144-sdk-key-rotation";
public const string EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration";
/* Mobile Team */
@@ -229,23 +230,12 @@ public static class FeatureFlagKeys
/// Enable this flag to share the send view used by the web and browser clients
/// on the desktop client.
/// </summary>
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators";
public const string UseChromiumImporter = "pm-23982-chromium-importer";
public const string ChromiumImporterWithABE = "pm-25855-chromium-importer-abe";
public const string SendUIRefresh = "pm-28175-send-ui-refresh";
public const string SendEmailOTP = "pm-19051-send-email-verification";
/// <summary>
/// Enable this flag to output email/OTP authenticated sends from the `GET sends` endpoint. When
/// this flag is disabled, the `GET sends` endpoint omits email/OTP authenticated sends.
/// </summary>
/// <remarks>
/// This flag is server-side only, and only inhibits the endpoint returning all sends.
/// Email/OTP sends can still be created and downloaded through other endpoints.
/// </remarks>
public const string PM19051_ListEmailOtpSends = "tools-send-email-otp-listing";
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<GenerateUserSecretsAttribute>false</GenerateUserSecretsAttribute>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -44,7 +46,7 @@
<PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.0" />
<PackageReference Include="Microsoft.Bot.Connector" Version="4.23.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.7.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Cosmos" Version="1.8.0" />
<PackageReference Include="Microsoft.Extensions.Caching.SqlServer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="8.0.0" />

View File

@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Utilities;
namespace Bit.Core.Entities;
/// <summary>
/// PlayItem is a join table tracking entities created during automated testing.
/// A `PlayId` is supplied by the clients in the `x-play-id` header to inform the server
/// that any data created should be associated with the play, and therefore cleaned up with it.
/// </summary>
public class PlayItem : ITableObject<Guid>
{
public Guid Id { get; set; }
[MaxLength(256)]
public required string PlayId { get; init; }
public Guid? UserId { get; init; }
public Guid? OrganizationId { get; init; }
public DateTime CreationDate { get; init; }
/// <summary>
/// Generates and sets a new COMB GUID for the Id property.
/// </summary>
public void SetNewId()
{
Id = CoreHelpers.GenerateComb();
}
/// <summary>
/// Creates a new PlayItem record associated with a User.
/// </summary>
/// <param name="user">The user entity created during the play.</param>
/// <param name="playId">The play identifier from the x-play-id header.</param>
/// <returns>A new PlayItem instance tracking the user.</returns>
public static PlayItem Create(User user, string playId)
{
return new PlayItem
{
PlayId = playId,
UserId = user.Id,
CreationDate = DateTime.UtcNow
};
}
/// <summary>
/// Creates a new PlayItem record associated with an Organization.
/// </summary>
/// <param name="organization">The organization entity created during the play.</param>
/// <param name="playId">The play identifier from the x-play-id header.</param>
/// <returns>A new PlayItem instance tracking the organization.</returns>
public static PlayItem Create(Organization organization, string playId)
{
return new PlayItem
{
PlayId = playId,
OrganizationId = organization.Id,
CreationDate = DateTime.UtcNow
};
}
}

View File

@@ -8,8 +8,8 @@
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/spot-enterprise.png"
title="You can now share passwords with members of {{OrganizationName}}!"
button-text="Log in"
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
button-text="<b>Log in</b>"
button-url="{{WebVaultUrl}}"
/>
</mj-wrapper>
@@ -33,7 +33,7 @@
icon-alt="Group Users Icon"
text="You can easily access and share passwords with your team."
foot-url-text="Share passwords in Bitwarden"
foot-url="https://bitwarden.com/help/share-to-a-collection/"
foot-url="https://bitwarden.com/help/sharing"
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>

View File

@@ -8,8 +8,8 @@
<mj-wrapper css-class="border-fix" padding="20px 20px 10px 20px">
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/spot-family-homes.png"
title="You can now share passwords with members of {{OrganizationName}}!"
button-text="Log in"
title="You can now share passwords with members of <b>{{OrganizationName}}!</b>"
button-text="<b>Log in</b>"
button-url="{{WebVaultUrl}}"
/>
</mj-wrapper>
@@ -33,7 +33,7 @@
icon-alt="Group Users Icon"
text="You can easily share passwords with friends, family, or coworkers."
foot-url-text="Share passwords in Bitwarden"
foot-url="https://bitwarden.com/help/share-to-a-collection/"
foot-url="https://bitwarden.com/help/sharing"
/>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>

View File

@@ -0,0 +1,11 @@
using Bit.Core.Entities;
#nullable enable
namespace Bit.Core.Repositories;
public interface IPlayItemRepository : IRepository<PlayItem, Guid>
{
Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId);
Task DeleteByPlayIdAsync(string playId);
}

View File

@@ -14,6 +14,8 @@ using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Models.Sales;
@@ -982,6 +984,16 @@ public class UserService : UserManager<User>, IUserService
throw new BadRequestException(exceptionMessage);
}
// If the license has a Token (claims-based), extract all properties from claims
// Otherwise, fall back to using the properties already on the license object (backward compatibility)
if (claimsPrincipal != null)
{
license.LicenseKey = claimsPrincipal.GetValue<string>(UserLicenseConstants.LicenseKey);
license.Premium = claimsPrincipal.GetValue<bool>(UserLicenseConstants.Premium);
license.MaxStorageGb = claimsPrincipal.GetValue<short?>(UserLicenseConstants.MaxStorageGb);
license.Expires = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
}
var dir = $"{_globalSettings.LicenseDirectory}/user";
Directory.CreateDirectory(dir);
using var fs = File.OpenWrite(Path.Combine(dir, $"{user.Id}.json"));

View File

@@ -0,0 +1,23 @@
namespace Bit.Core.Services;
/// <summary>
/// Service for managing Play identifiers in automated testing infrastructure.
/// A "Play" is a test session that groups entities created during testing to enable cleanup.
/// The PlayId flows from client request (x-play-id header) through PlayIdMiddleware to this service,
/// which repositories query to create PlayItem tracking records via IPlayItemService. The SeederAPI uses these records
/// to bulk delete all entities associated with a PlayId. Only active in Development environments.
/// </summary>
public interface IPlayIdService
{
/// <summary>
/// Gets or sets the current Play identifier from the x-play-id request header.
/// </summary>
string? PlayId { get; set; }
/// <summary>
/// Checks whether the current request is part of an active Play session.
/// </summary>
/// <param name="playId">The Play identifier if active, otherwise empty string.</param>
/// <returns>True if in a Play session (has PlayId and in Development environment), otherwise false.</returns>
bool InPlay(out string playId);
}

View File

@@ -0,0 +1,27 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
namespace Bit.Core.Services;
/// <summary>
/// Service used to track added users and organizations during a Play session.
/// </summary>
public interface IPlayItemService
{
/// <summary>
/// Records a PlayItem entry for the given User created during a Play session.
///
/// Does nothing if no Play Id is set for this http scope.
/// </summary>
/// <param name="user"></param>
/// <returns></returns>
Task Record(User user);
/// <summary>
/// Records a PlayItem entry for the given Organization created during a Play session.
///
/// Does nothing if no Play Id is set for this http scope.
/// </summary>
/// <param name="organization"></param>
/// <returns></returns>
Task Record(Organization organization);
}

View File

@@ -0,0 +1,16 @@
namespace Bit.Core.Services;
public class NeverPlayIdServices : IPlayIdService
{
public string? PlayId
{
get => null;
set { }
}
public bool InPlay(out string playId)
{
playId = string.Empty;
return false;
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.Extensions.Hosting;
namespace Bit.Core.Services;
public class PlayIdService(IHostEnvironment hostEnvironment) : IPlayIdService
{
public string? PlayId { get; set; }
public bool InPlay(out string playId)
{
playId = PlayId ?? string.Empty;
return !string.IsNullOrEmpty(PlayId) && hostEnvironment.IsDevelopment();
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Bit.Core.Services;
/// <summary>
/// Singleton wrapper service that bridges singleton-scoped service boundaries for PlayId tracking.
/// This allows singleton services to access the scoped PlayIdService via HttpContext.RequestServices.
///
/// Uses IHttpContextAccessor to retrieve the current request's scoped PlayIdService instance, enabling
/// singleton services to participate in Play session tracking without violating DI lifetime rules.
/// Falls back to NeverPlayIdServices when no HttpContext is available (e.g., background jobs).
/// </summary>
public class PlayIdSingletonService(IHttpContextAccessor httpContextAccessor, IHostEnvironment hostEnvironment) : IPlayIdService
{
private IPlayIdService Current
{
get
{
var httpContext = httpContextAccessor.HttpContext;
if (httpContext == null)
{
return new NeverPlayIdServices();
}
return httpContext.RequestServices.GetRequiredService<PlayIdService>();
}
}
public string? PlayId
{
get => Current.PlayId;
set => Current.PlayId = value;
}
public bool InPlay(out string playId)
{
if (hostEnvironment.IsDevelopment())
{
return Current.InPlay(out playId);
}
else
{
playId = string.Empty;
return false;
}
}
}

View File

@@ -0,0 +1,26 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class PlayItemService(IPlayIdService playIdService, IPlayItemRepository playItemRepository, ILogger<PlayItemService> logger) : IPlayItemService
{
public async Task Record(User user)
{
if (playIdService.InPlay(out var playId))
{
logger.LogInformation("Associating user {UserId} with Play ID {PlayId}", user.Id, playId);
await playItemRepository.CreateAsync(PlayItem.Create(user, playId));
}
}
public async Task Record(Organization organization)
{
if (playIdService.InPlay(out var playId))
{
logger.LogInformation("Associating organization {OrganizationId} with Play ID {PlayId}", organization.Id, playId);
await playItemRepository.CreateAsync(PlayItem.Create(organization, playId));
}
}
}

View File

@@ -0,0 +1,27 @@
# Play Services
## Overview
The Play services provide automated testing infrastructure for tracking and cleaning up test data in development
environments. A "Play" is a test session that groups entities (users, organizations, etc.) created during testing to
enable bulk cleanup via the SeederAPI.
## How It Works
1. Test client sends `x-play-id` header with a unique Play identifier
2. `PlayIdMiddleware` extracts the header and sets it on `IPlayIdService`
3. Repositories check `IPlayIdService.InPlay()` when creating entities
4. `IPlayItemService` records PlayItem entries for tracked entities
5. SeederAPI uses PlayItem records to bulk delete all entities associated with a PlayId
Play services are **only active in Development environments**.
## Classes
- **`IPlayIdService`** - Interface for managing Play identifiers in the current request scope
- **`IPlayItemService`** - Interface for tracking entities created during a Play session
- **`PlayIdService`** - Default scoped implementation for tracking Play sessions per HTTP request
- **`NeverPlayIdServices`** - No-op implementation used as fallback when no HttpContext is available
- **`PlayIdSingletonService`** - Singleton wrapper that allows singleton services to access scoped PlayIdService via
HttpContext
- **`PlayItemService`** - Implementation that records PlayItem entries for entities created during Play sessions

View File

@@ -44,6 +44,7 @@ public class GlobalSettings : IGlobalSettings
public virtual bool EnableCloudCommunication { get; set; } = false;
public virtual int OrganizationInviteExpirationHours { get; set; } = 120; // 5 days
public virtual string EventGridKey { get; set; }
public virtual bool TestPlayIdTrackingEnabled { get; set; } = false;
public virtual IInstallationSettings Installation { get; set; } = new InstallationSettings();
public virtual IBaseServiceUriSettings BaseServiceUri { get; set; }
public virtual string DatabaseProvider { get; set; }

View File

@@ -1,11 +1,8 @@
using System.Text.Json.Serialization;
namespace Bit.Core.Tools.Enums;
namespace Bit.Core.Tools.Enums;
/// <summary>
/// Specifies the authentication method required to access a Send.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum AuthType : byte
{
/// <summary>

View File

@@ -1,4 +1,5 @@
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.SendFeatures.Queries.Interfaces;
@@ -37,8 +38,8 @@ public class SendAuthenticationQuery : ISendAuthenticationQuery
{
null => NEVER_AUTHENTICATE,
var s when s.AccessCount >= s.MaxAccessCount => NEVER_AUTHENTICATE,
var s when s.Emails is not null => emailOtp(s.Emails),
var s when s.Password is not null => new ResourcePassword(s.Password),
var s when s.AuthType == AuthType.Email && s.Emails is not null => emailOtp(s.Emails),
var s when s.AuthType == AuthType.Password && s.Password is not null => new ResourcePassword(s.Password),
_ => NOT_AUTHENTICATED
};

View File

@@ -12,7 +12,6 @@ namespace Bit.Core.Tools.SendFeatures.Queries;
public class SendOwnerQuery : ISendOwnerQuery
{
private readonly ISendRepository _repository;
private readonly IFeatureService _features;
private readonly IUserService _users;
/// <summary>
@@ -24,10 +23,9 @@ public class SendOwnerQuery : ISendOwnerQuery
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="sendRepository"/> is <see langword="null"/>.
/// </exception>
public SendOwnerQuery(ISendRepository sendRepository, IFeatureService features, IUserService users)
public SendOwnerQuery(ISendRepository sendRepository, IUserService users)
{
_repository = sendRepository;
_features = features ?? throw new ArgumentNullException(nameof(features));
_users = users ?? throw new ArgumentNullException(nameof(users));
}
@@ -51,16 +49,6 @@ public class SendOwnerQuery : ISendOwnerQuery
var userId = _users.GetProperUserId(user) ?? throw new BadRequestException("invalid user.");
var sends = await _repository.GetManyByUserIdAsync(userId);
var removeEmailOtp = !_features.IsEnabled(FeatureFlagKeys.PM19051_ListEmailOtpSends);
if (removeEmailOtp)
{
// reify list to avoid invalidating the enumerator
foreach (var s in sends.Where(s => s.Emails != null).ToList())
{
sends.Remove(s);
}
}
return sends;
}
}

View File

@@ -36,6 +36,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -30,6 +30,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Add event integration services
services.AddDistributedCache(globalSettings);

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Icons</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Icons' " />

View File

@@ -3,6 +3,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Identity</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Identity' " />

View File

@@ -49,6 +49,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();

View File

@@ -17,7 +17,7 @@ namespace Bit.Infrastructure.Dapper.Repositories;
public class OrganizationRepository : Repository<Organization, Guid>, IOrganizationRepository
{
private readonly ILogger<OrganizationRepository> _logger;
protected readonly ILogger<OrganizationRepository> _logger;
public OrganizationRepository(
GlobalSettings globalSettings,

View File

@@ -51,6 +51,7 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
services.AddSingleton<IPolicyRepository, PolicyRepository>();
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
services.AddSingleton<IProviderRepository, ProviderRepository>();

View File

@@ -1,5 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,45 @@
using System.Data;
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Dapper;
using Microsoft.Data.SqlClient;
#nullable enable
namespace Bit.Infrastructure.Dapper.Repositories;
public class PlayItemRepository : Repository<PlayItem, Guid>, IPlayItemRepository
{
public PlayItemRepository(GlobalSettings globalSettings)
: this(globalSettings.SqlServer.ConnectionString, globalSettings.SqlServer.ReadOnlyConnectionString)
{ }
public PlayItemRepository(string connectionString, string readOnlyConnectionString)
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<ICollection<PlayItem>> GetByPlayIdAsync(string playId)
{
using (var connection = new SqlConnection(ConnectionString))
{
var results = await connection.QueryAsync<PlayItem>(
"[dbo].[PlayItem_ReadByPlayId]",
new { PlayId = playId },
commandType: CommandType.StoredProcedure);
return results.ToList();
}
}
public async Task DeleteByPlayIdAsync(string playId)
{
using (var connection = new SqlConnection(ConnectionString))
{
await connection.ExecuteAsync(
"[dbo].[PlayItem_DeleteByPlayId]",
new { PlayId = playId },
commandType: CommandType.StoredProcedure);
}
}
}

View File

@@ -20,7 +20,7 @@ namespace Bit.Infrastructure.EntityFramework.Repositories;
public class OrganizationRepository : Repository<Core.AdminConsole.Entities.Organization, Organization, Guid>, IOrganizationRepository
{
private readonly ILogger<OrganizationRepository> _logger;
protected readonly ILogger<OrganizationRepository> _logger;
public OrganizationRepository(
IServiceScopeFactory serviceScopeFactory,

View File

@@ -0,0 +1,46 @@
using Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Bit.Infrastructure.EntityFramework.Configurations;
public class PlayItemEntityTypeConfiguration : IEntityTypeConfiguration<PlayItem>
{
public void Configure(EntityTypeBuilder<PlayItem> builder)
{
builder
.Property(pd => pd.Id)
.ValueGeneratedNever();
builder
.HasIndex(pd => pd.PlayId)
.IsClustered(false);
builder
.HasIndex(pd => pd.UserId)
.IsClustered(false);
builder
.HasIndex(pd => pd.OrganizationId)
.IsClustered(false);
builder
.HasOne(pd => pd.User)
.WithMany()
.HasForeignKey(pd => pd.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder
.HasOne(pd => pd.Organization)
.WithMany()
.HasForeignKey(pd => pd.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder
.ToTable(nameof(PlayItem))
.HasCheckConstraint(
"CK_PlayItem_UserOrOrganization",
"(\"UserId\" IS NOT NULL AND \"OrganizationId\" IS NULL) OR (\"UserId\" IS NULL AND \"OrganizationId\" IS NOT NULL)"
);
}
}

View File

@@ -88,6 +88,7 @@ public static class EntityFrameworkServiceCollectionExtensions
services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();
services.AddSingleton<IPlayItemRepository, PlayItemRepository>();
services.AddSingleton<IPolicyRepository, PolicyRepository>();
services.AddSingleton<IProviderOrganizationRepository, ProviderOrganizationRepository>();
services.AddSingleton<IProviderRepository, ProviderRepository>();

View File

@@ -1,4 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />

View File

@@ -0,0 +1,19 @@
#nullable enable
using AutoMapper;
namespace Bit.Infrastructure.EntityFramework.Models;
public class PlayItem : Core.Entities.PlayItem
{
public virtual User? User { get; set; }
public virtual AdminConsole.Models.Organization? Organization { get; set; }
}
public class PlayItemMapperProfile : Profile
{
public PlayItemMapperProfile()
{
CreateMap<Core.Entities.PlayItem, PlayItem>().ReverseMap();
}
}

View File

@@ -57,6 +57,7 @@ public class DatabaseContext : DbContext
public DbSet<OrganizationApiKey> OrganizationApiKeys { get; set; }
public DbSet<OrganizationSponsorship> OrganizationSponsorships { get; set; }
public DbSet<OrganizationConnection> OrganizationConnections { get; set; }
public DbSet<PlayItem> PlayItem { get; set; }
public DbSet<OrganizationIntegration> OrganizationIntegrations { get; set; }
public DbSet<OrganizationIntegrationConfiguration> OrganizationIntegrationConfigurations { get; set; }
public DbSet<OrganizationUser> OrganizationUsers { get; set; }

View File

@@ -0,0 +1,42 @@
using AutoMapper;
using Bit.Core.Repositories;
using Bit.Infrastructure.EntityFramework.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
#nullable enable
namespace Bit.Infrastructure.EntityFramework.Repositories;
public class PlayItemRepository : Repository<Core.Entities.PlayItem, PlayItem, Guid>, IPlayItemRepository
{
public PlayItemRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, (DatabaseContext context) => context.PlayItem)
{ }
public async Task<ICollection<Core.Entities.PlayItem>> GetByPlayIdAsync(string playId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var playItemEntities = await GetDbSet(dbContext)
.Where(pd => pd.PlayId == playId)
.ToListAsync();
return Mapper.Map<List<Core.Entities.PlayItem>>(playItemEntities);
}
}
public async Task DeleteByPlayIdAsync(string playId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entities = await GetDbSet(dbContext)
.Where(pd => pd.PlayId == playId)
.ToListAsync();
dbContext.PlayItem.RemoveRange(entities);
await dbContext.SaveChangesAsync();
}
}
}

View File

@@ -801,7 +801,7 @@ public class CipherRepository : Repository<Core.Vault.Entities.Cipher, Cipher, G
var query = from ucd in await userCipherDetailsQuery.Run(dbContext).ToListAsync()
join c in cipherEntitiesToCheck
on ucd.Id equals c.Id
where ucd.Edit && FilterArchivedDate(action, ucd)
where FilterArchivedDate(action, ucd)
select c;
var utcNow = DateTime.UtcNow;

View File

@@ -0,0 +1,30 @@
using Bit.Core.Repositories;
using Bit.SharedWeb.Play.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.SharedWeb.Play;
public static class PlayServiceCollectionExtensions
{
/// <summary>
/// Adds PlayId tracking decorators for User and Organization repositories using Dapper implementations.
/// This replaces the standard repository implementations with tracking versions
/// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true.
/// </summary>
public static void AddPlayIdTrackingDapperRepositories(this IServiceCollection services)
{
services.AddSingleton<IOrganizationRepository, DapperTestOrganizationTrackingOrganizationRepository>();
services.AddSingleton<IUserRepository, DapperTestUserTrackingUserRepository>();
}
/// <summary>
/// Adds PlayId tracking decorators for User and Organization repositories using EntityFramework implementations.
/// This replaces the standard repository implementations with tracking versions
/// that record created entities for test data cleanup. Only call when TestPlayIdTrackingEnabled is true.
/// </summary>
public static void AddPlayIdTrackingEFRepositories(this IServiceCollection services)
{
services.AddSingleton<IOrganizationRepository, EFTestOrganizationTrackingOrganizationRepository>();
services.AddSingleton<IUserRepository, EFTestUserTrackingUserRepository>();
}
}

View File

@@ -0,0 +1,32 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Infrastructure.Dapper.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.SharedWeb.Play.Repositories;
/// <summary>
/// Dapper decorator around the <see cref="Bit.Infrastructure.Dapper.Repositories.OrganizationRepository"/> that tracks
/// created Organizations for seeding.
/// </summary>
public class DapperTestOrganizationTrackingOrganizationRepository : OrganizationRepository
{
private readonly IPlayItemService _playItemService;
public DapperTestOrganizationTrackingOrganizationRepository(
IPlayItemService playItemService,
GlobalSettings globalSettings,
ILogger<OrganizationRepository> logger)
: base(globalSettings, logger)
{
_playItemService = playItemService;
}
public override async Task<Organization> CreateAsync(Organization obj)
{
var createdOrganization = await base.CreateAsync(obj);
await _playItemService.Record(createdOrganization);
return createdOrganization;
}
}

View File

@@ -0,0 +1,33 @@
using Bit.Core.Entities;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Infrastructure.Dapper.Repositories;
using Microsoft.AspNetCore.DataProtection;
namespace Bit.SharedWeb.Play.Repositories;
/// <summary>
/// Dapper decorator around the <see cref="Bit.Infrastructure.Dapper.Repositories.UserRepository"/> that tracks
/// created Users for seeding.
/// </summary>
public class DapperTestUserTrackingUserRepository : UserRepository
{
private readonly IPlayItemService _playItemService;
public DapperTestUserTrackingUserRepository(
IPlayItemService playItemService,
GlobalSettings globalSettings,
IDataProtectionProvider dataProtectionProvider)
: base(globalSettings, dataProtectionProvider)
{
_playItemService = playItemService;
}
public override async Task<User> CreateAsync(User user)
{
var createdUser = await base.CreateAsync(user);
await _playItemService.Record(createdUser);
return createdUser;
}
}

View File

@@ -0,0 +1,33 @@
using AutoMapper;
using Bit.Core.Services;
using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Bit.SharedWeb.Play.Repositories;
/// <summary>
/// EntityFramework decorator around the <see cref="Bit.Infrastructure.EntityFramework.Repositories.OrganizationRepository"/> that tracks
/// created Organizations for seeding.
/// </summary>
public class EFTestOrganizationTrackingOrganizationRepository : OrganizationRepository
{
private readonly IPlayItemService _playItemService;
public EFTestOrganizationTrackingOrganizationRepository(
IPlayItemService playItemService,
IServiceScopeFactory serviceScopeFactory,
IMapper mapper,
ILogger<OrganizationRepository> logger)
: base(serviceScopeFactory, mapper, logger)
{
_playItemService = playItemService;
}
public override async Task<Core.AdminConsole.Entities.Organization> CreateAsync(Core.AdminConsole.Entities.Organization organization)
{
var createdOrganization = await base.CreateAsync(organization);
await _playItemService.Record(createdOrganization);
return createdOrganization;
}
}

View File

@@ -0,0 +1,31 @@
using AutoMapper;
using Bit.Core.Services;
using Bit.Infrastructure.EntityFramework.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.SharedWeb.Play.Repositories;
/// <summary>
/// EntityFramework decorator around the <see cref="Bit.Infrastructure.EntityFramework.Repositories.UserRepository"/> that tracks
/// created Users for seeding.
/// </summary>
public class EFTestUserTrackingUserRepository : UserRepository
{
private readonly IPlayItemService _playItemService;
public EFTestUserTrackingUserRepository(
IPlayItemService playItemService,
IServiceScopeFactory serviceScopeFactory,
IMapper mapper)
: base(serviceScopeFactory, mapper)
{
_playItemService = playItemService;
}
public override async Task<Core.Entities.User> CreateAsync(Core.Entities.User user)
{
var createdUser = await base.CreateAsync(user);
await _playItemService.Record(createdUser);
return createdUser;
}
}

View File

@@ -1,5 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Infrastructure.Dapper\Infrastructure.Dapper.csproj" />
<ProjectReference Include="..\Core\Core.csproj" />

View File

@@ -0,0 +1,41 @@
using Bit.Core.Services;
using Microsoft.AspNetCore.Http;
namespace Bit.SharedWeb.Utilities;
/// <summary>
/// Middleware to extract the x-play-id header and set it in the PlayIdService.
///
/// PlayId is used in testing infrastructure to track data created during automated testing and fa cilitate cleanup.
/// </summary>
/// <param name="next"></param>
public sealed class PlayIdMiddleware(RequestDelegate next)
{
private const int MaxPlayIdLength = 256;
public async Task Invoke(HttpContext context, PlayIdService playIdService)
{
if (context.Request.Headers.TryGetValue("x-play-id", out var playId))
{
var playIdValue = playId.ToString();
if (string.IsNullOrWhiteSpace(playIdValue))
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new { Error = "x-play-id header cannot be empty or whitespace" });
return;
}
if (playIdValue.Length > MaxPlayIdLength)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new { Error = $"x-play-id header cannot exceed {MaxPlayIdLength} characters" });
return;
}
playIdService.PlayId = playIdValue;
}
await next(context);
}
}

View File

@@ -56,6 +56,7 @@ using Bit.Core.Vault;
using Bit.Core.Vault.Services;
using Bit.Infrastructure.Dapper;
using Bit.Infrastructure.EntityFramework;
using Bit.SharedWeb.Play;
using DnsClient;
using Duende.IdentityModel;
using LaunchDarkly.Sdk.Server;
@@ -117,6 +118,40 @@ public static class ServiceCollectionExtensions
return provider;
}
/// <summary>
/// Registers test PlayId tracking services for test data management and cleanup.
/// This infrastructure is isolated to test environments and enables tracking of test-generated entities.
/// </summary>
public static void AddTestPlayIdTracking(this IServiceCollection services, GlobalSettings globalSettings)
{
if (globalSettings.TestPlayIdTrackingEnabled)
{
var (provider, _) = GetDatabaseProvider(globalSettings);
// Include PlayIdService for tracking Play Ids in repositories
// We need the http context accessor to use the Singleton version, which pulls from the scoped version
services.AddHttpContextAccessor();
services.AddSingleton<IPlayItemService, PlayItemService>();
services.AddSingleton<IPlayIdService, PlayIdSingletonService>();
services.AddScoped<PlayIdService>();
// Replace standard repositories with PlayId tracking decorators
if (provider == SupportedDatabaseProviders.SqlServer)
{
services.AddPlayIdTrackingDapperRepositories();
}
else
{
services.AddPlayIdTrackingEFRepositories();
}
}
else
{
services.AddSingleton<IPlayIdService, NeverPlayIdServices>();
}
}
public static void AddBaseServices(this IServiceCollection services, IGlobalSettings globalSettings)
{
services.AddScoped<ICipherService, CipherService>();
@@ -522,6 +557,10 @@ public static class ServiceCollectionExtensions
IWebHostEnvironment env, GlobalSettings globalSettings)
{
app.UseMiddleware<RequestLoggingMiddleware>();
if (globalSettings.TestPlayIdTrackingEnabled)
{
app.UseMiddleware<PlayIdMiddleware>();
}
}
public static void UseForwardedHeaders(this IApplicationBuilder app, IGlobalSettings globalSettings)

View File

@@ -0,0 +1,27 @@
CREATE PROCEDURE [dbo].[PlayItem_Create]
@Id UNIQUEIDENTIFIER OUTPUT,
@PlayId NVARCHAR(256),
@UserId UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@CreationDate DATETIME2(7)
AS
BEGIN
SET NOCOUNT ON
INSERT INTO [dbo].[PlayItem]
(
[Id],
[PlayId],
[UserId],
[OrganizationId],
[CreationDate]
)
VALUES
(
@Id,
@PlayId,
@UserId,
@OrganizationId,
@CreationDate
)
END

View File

@@ -0,0 +1,12 @@
CREATE PROCEDURE [dbo].[PlayItem_DeleteByPlayId]
@PlayId NVARCHAR(256)
AS
BEGIN
SET NOCOUNT ON
DELETE
FROM
[dbo].[PlayItem]
WHERE
[PlayId] = @PlayId
END

View File

@@ -0,0 +1,17 @@
CREATE PROCEDURE [dbo].[PlayItem_ReadByPlayId]
@PlayId NVARCHAR(256)
AS
BEGIN
SET NOCOUNT ON
SELECT
[Id],
[PlayId],
[UserId],
[OrganizationId],
[CreationDate]
FROM
[dbo].[PlayItem]
WHERE
[PlayId] = @PlayId
END

View File

@@ -0,0 +1,23 @@
CREATE TABLE [dbo].[PlayItem] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[PlayId] NVARCHAR (256) NOT NULL,
[UserId] UNIQUEIDENTIFIER NULL,
[OrganizationId] UNIQUEIDENTIFIER NULL,
[CreationDate] DATETIME2 (7) NOT NULL,
CONSTRAINT [PK_PlayItem] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PlayItem_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]) ON DELETE CASCADE,
CONSTRAINT [FK_PlayItem_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE,
CONSTRAINT [CK_PlayItem_UserOrOrganization] CHECK (([UserId] IS NOT NULL AND [OrganizationId] IS NULL) OR ([UserId] IS NULL AND [OrganizationId] IS NOT NULL))
);
GO
CREATE NONCLUSTERED INDEX [IX_PlayItem_PlayId]
ON [dbo].[PlayItem]([PlayId] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_PlayItem_UserId]
ON [dbo].[PlayItem]([UserId] ASC);
GO
CREATE NONCLUSTERED INDEX [IX_PlayItem_OrganizationId]
ON [dbo].[PlayItem]([OrganizationId] ASC);

View File

@@ -13,14 +13,13 @@ BEGIN
INSERT INTO #Temp
SELECT
[Id],
[UserId]
ucd.[Id],
ucd.[UserId]
FROM
[dbo].[UserCipherDetails](@UserId)
[dbo].[UserCipherDetails](@UserId) ucd
INNER JOIN @Ids ids ON ids.Id = ucd.[Id]
WHERE
[Edit] = 1
AND [ArchivedDate] IS NULL
AND [Id] IN (SELECT * FROM @Ids)
ucd.[ArchivedDate] IS NULL
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
UPDATE
@@ -32,8 +31,9 @@ BEGIN
CONVERT(NVARCHAR(30), @UtcNow, 127)
),
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)
FROM [dbo].[Cipher] AS c
INNER JOIN #Temp AS t
ON t.[Id] = c.[Id];
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId

View File

@@ -13,14 +13,13 @@ BEGIN
INSERT INTO #Temp
SELECT
[Id],
[UserId]
ucd.[Id],
ucd.[UserId]
FROM
[dbo].[UserCipherDetails](@UserId)
[dbo].[UserCipherDetails](@UserId) ucd
INNER JOIN @Ids ids ON ids.Id = ucd.[Id]
WHERE
[Edit] = 1
AND [ArchivedDate] IS NOT NULL
AND [Id] IN (SELECT * FROM @Ids)
ucd.[ArchivedDate] IS NOT NULL
DECLARE @UtcNow DATETIME2(7) = SYSUTCDATETIME();
UPDATE
@@ -32,8 +31,9 @@ BEGIN
NULL
),
[RevisionDate] = @UtcNow
WHERE
[Id] IN (SELECT [Id] FROM #Temp)
FROM [dbo].[Cipher] AS c
INNER JOIN #Temp AS t
ON t.[Id] = c.[Id];
EXEC [dbo].[User_BumpAccountRevisionDate] @UserId

View File

@@ -4,7 +4,6 @@ using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Api.IntegrationTest.Factories;
using Bit.Api.IntegrationTest.Helpers;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
@@ -14,8 +13,6 @@ using Bit.Core.Billing.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using NSubstitute;
using Xunit;
namespace Bit.Api.IntegrationTest.AdminConsole.Controllers;
@@ -32,12 +29,6 @@ public class OrganizationUserControllerBulkRevokeTests : IClassFixture<ApiApplic
public OrganizationUserControllerBulkRevokeTests(ApiApplicationFactory apiFactory)
{
_factory = apiFactory;
_factory.SubstituteService<IFeatureService>(featureService =>
{
featureService
.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2)
.Returns(true);
});
_client = _factory.CreateClient();
_loginHelper = new LoginHelper(_factory, _client);
}

View File

@@ -1,6 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -8,8 +8,8 @@ using Bit.Api.Tools.Models.Request;
using Bit.Api.Tools.Models.Response;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Data;
@@ -28,7 +28,6 @@ namespace Bit.Api.Test.Tools.Controllers;
public class SendsControllerTests : IDisposable
{
private readonly SendsController _sut;
private readonly GlobalSettings _globalSettings;
private readonly IUserService _userService;
private readonly ISendRepository _sendRepository;
private readonly INonAnonymousSendCommand _nonAnonymousSendCommand;
@@ -37,6 +36,8 @@ public class SendsControllerTests : IDisposable
private readonly ISendAuthorizationService _sendAuthorizationService;
private readonly ISendFileStorageService _sendFileStorageService;
private readonly ILogger<SendsController> _logger;
private readonly IFeatureService _featureService;
private readonly IPushNotificationService _pushNotificationService;
public SendsControllerTests()
{
@@ -47,8 +48,9 @@ public class SendsControllerTests : IDisposable
_sendOwnerQuery = Substitute.For<ISendOwnerQuery>();
_sendAuthorizationService = Substitute.For<ISendAuthorizationService>();
_sendFileStorageService = Substitute.For<ISendFileStorageService>();
_globalSettings = new GlobalSettings();
_logger = Substitute.For<ILogger<SendsController>>();
_featureService = Substitute.For<IFeatureService>();
_pushNotificationService = Substitute.For<IPushNotificationService>();
_sut = new SendsController(
_sendRepository,
@@ -59,7 +61,8 @@ public class SendsControllerTests : IDisposable
_sendOwnerQuery,
_sendFileStorageService,
_logger,
_globalSettings
_featureService,
_pushNotificationService
);
}
@@ -96,8 +99,8 @@ public class SendsControllerTests : IDisposable
{
var now = DateTime.UtcNow;
var expected = "You cannot have a Send with a deletion date that far " +
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
"and try again.";
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
"and try again.";
var request = new SendRequestModel() { DeletionDate = now.AddDays(32) };
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.Post(request));
@@ -109,9 +112,10 @@ public class SendsControllerTests : IDisposable
{
var now = DateTime.UtcNow;
var expected = "You cannot have a Send with a deletion date that far " +
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
"and try again.";
var request = new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
"into the future. Adjust the Deletion Date to a value less than 31 days from now " +
"and try again.";
var request =
new SendRequestModel() { Type = SendType.File, FileLength = 1024L, DeletionDate = now.AddDays(32) };
var exception = await Assert.ThrowsAsync<BadRequestException>(() => _sut.PostFile(request));
Assert.Equal(expected, exception.Message);
@@ -409,7 +413,8 @@ public class SendsControllerTests : IDisposable
}
[Theory, AutoData]
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
public async Task PutRemovePassword_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId,
Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
@@ -753,4 +758,683 @@ public class SendsControllerTests : IDisposable
s.Password == null &&
s.Emails == null));
}
#region Authenticated Access Endpoints
[Theory, AutoData]
public async Task AccessUsingAuth_WithValidSend_ReturnsSendAccessResponse(Guid sendId, User creator)
{
var send = new Send
{
Id = sendId,
UserId = creator.Id,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
HideEmail = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
_userService.GetUserByIdAsync(creator.Id).Returns(creator);
var result = await _sut.AccessUsingAuth();
Assert.NotNull(result);
var objectResult = Assert.IsType<ObjectResult>(result);
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
Assert.Equal(creator.Email, response.CreatorIdentifier);
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _userService.Received(1).GetUserByIdAsync(creator.Id);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithHideEmail_DoesNotIncludeCreatorIdentifier(Guid sendId, User creator)
{
var send = new Send
{
Id = sendId,
UserId = creator.Id,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
HideEmail = true,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
var result = await _sut.AccessUsingAuth();
Assert.NotNull(result);
var objectResult = Assert.IsType<ObjectResult>(result);
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
Assert.Null(response.CreatorIdentifier);
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any<Guid>());
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithNoUserId_DoesNotIncludeCreatorIdentifier(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = null,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
HideEmail = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
var result = await _sut.AccessUsingAuth();
Assert.NotNull(result);
var objectResult = Assert.IsType<ObjectResult>(result);
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
Assert.Null(response.CreatorIdentifier);
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _userService.DidNotReceive().GetUserByIdAsync(Arg.Any<Guid>());
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithNonExistentSend_ThrowsBadRequestException(Guid sendId)
{
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
var exception =
await Assert.ThrowsAsync<BadRequestException>(() => _sut.AccessUsingAuth());
Assert.Equal("Could not locate send", exception.Message);
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithFileSend_ReturnsCorrectResponse(Guid sendId, User creator)
{
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
var send = new Send
{
Id = sendId,
UserId = creator.Id,
Type = SendType.File,
Data = JsonSerializer.Serialize(fileData),
HideEmail = false,
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
_userService.GetUserByIdAsync(creator.Id).Returns(creator);
var result = await _sut.AccessUsingAuth();
Assert.NotNull(result);
var objectResult = Assert.IsType<ObjectResult>(result);
var response = Assert.IsType<SendAccessResponseModel>(objectResult.Value);
Assert.Equal(CoreHelpers.Base64UrlEncode(sendId.ToByteArray()), response.Id);
Assert.Equal(SendType.File, response.Type);
Assert.NotNull(response.File);
Assert.Equal("file-123", response.File.Id);
Assert.Equal(creator.Email, response.CreatorIdentifier);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithValidFileId_ReturnsDownloadUrl(
Guid sendId, string fileId, string expectedUrl)
{
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = fileId, Size = 2048 };
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(fileData),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
_sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
Assert.NotNull(result);
var objectResult = Assert.IsType<ObjectResult>(result);
var response = Assert.IsType<SendFileDownloadDataResponseModel>(objectResult.Value);
Assert.Equal(fileId, response.Id);
Assert.Equal(expectedUrl, response.Url);
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _sendFileStorageService.Received(1).GetSendFileDownloadUrlAsync(send, fileId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithNonExistentSend_ThrowsBadRequestException(
Guid sendId, string fileId)
{
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
var exception =
await Assert.ThrowsAsync<BadRequestException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
Assert.Equal("Could not locate send", exception.Message);
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _sendFileStorageService.DidNotReceive()
.GetSendFileDownloadUrlAsync(Arg.Any<Send>(), Arg.Any<string>());
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithTextSend_StillReturnsResponse(
Guid sendId, string fileId, string expectedUrl)
{
var send = new Send
{
Id = sendId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
_sendFileStorageService.GetSendFileDownloadUrlAsync(send, fileId).Returns(expectedUrl);
var result = await _sut.GetSendFileDownloadDataUsingAuth(fileId);
Assert.NotNull(result);
var objectResult = Assert.IsType<ObjectResult>(result);
var response = Assert.IsType<SendFileDownloadDataResponseModel>(objectResult.Value);
Assert.Equal(fileId, response.Id);
Assert.Equal(expectedUrl, response.Url);
}
#region AccessUsingAuth Validation Tests
[Theory, AutoData]
public async Task AccessUsingAuth_WithExpiredSend_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired yesterday
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithDeletedSend_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(-1), // Should have been deleted yesterday
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithDisabledSend_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = true, // Disabled
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task AccessUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(Guid sendId)
{
var send = new Send
{
Id = sendId,
UserId = Guid.NewGuid(),
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 5,
MaxAccessCount = 5 // Limit reached
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.AccessUsingAuth());
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
#endregion
#region GetSendFileDownloadDataUsingAuth Validation Tests
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithExpiredSend_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = DateTime.UtcNow.AddDays(-1), // Expired
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithDeletedSend_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(-1), // Deleted
ExpirationDate = null,
Disabled = false,
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithDisabledSend_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = true, // Disabled
AccessCount = 0,
MaxAccessCount = null
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
[Theory, AutoData]
public async Task GetSendFileDownloadDataUsingAuth_WithAccessCountExceeded_ThrowsNotFoundException(
Guid sendId, string fileId)
{
var send = new Send
{
Id = sendId,
Type = SendType.File,
Data = JsonSerializer.Serialize(new SendFileData("Test", "Notes", "file.pdf")),
DeletionDate = DateTime.UtcNow.AddDays(7),
ExpirationDate = null,
Disabled = false,
AccessCount = 10,
MaxAccessCount = 10 // Limit reached
};
var user = CreateUserWithSendIdClaim(sendId);
_sut.ControllerContext = CreateControllerContextWithUser(user);
_sendRepository.GetByIdAsync(sendId).Returns(send);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.GetSendFileDownloadDataUsingAuth(fileId));
await _sendRepository.Received(1).GetByIdAsync(sendId);
}
#endregion
#endregion
#region PutRemoveAuth Tests
[Theory, AutoData]
public async Task PutRemoveAuth_WithPasswordProtectedSend_RemovesPasswordAndSetsAuthTypeNone(Guid userId,
Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password",
Emails = null,
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemoveAuth(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
Assert.Null(result.Password);
Assert.Null(result.Emails);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.Password == null &&
s.Emails == null &&
s.AuthType == AuthType.None));
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithEmailProtectedSend_RemovesEmailsAndSetsAuthTypeNone(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = null,
Emails = "test@example.com,user@example.com",
AuthType = AuthType.Email
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemoveAuth(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
Assert.Null(result.Password);
Assert.Null(result.Emails);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.Password == null &&
s.Emails == null &&
s.AuthType == AuthType.None));
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithSendAlreadyHavingNoAuth_StillSucceeds(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = null,
Emails = null,
AuthType = AuthType.None
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemoveAuth(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
Assert.Null(result.Password);
Assert.Null(result.Emails);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.Password == null &&
s.Emails == null &&
s.AuthType == AuthType.None));
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithFileSend_RemovesAuthAndPreservesFileData(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var fileData = new SendFileData("Test File", "Notes", "document.pdf") { Id = "file-123", Size = 2048 };
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.File,
Data = JsonSerializer.Serialize(fileData),
Password = "hashed-password",
Emails = null,
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemoveAuth(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
Assert.Equal(SendType.File, result.Type);
Assert.NotNull(result.File);
Assert.Equal("file-123", result.File.Id);
Assert.Null(result.Password);
Assert.Null(result.Emails);
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithNonExistentSend_ThrowsNotFoundException(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
_sendRepository.GetByIdAsync(sendId).Returns((Send)null);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemoveAuth(sendId.ToString()));
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithWrongUser_ThrowsNotFoundException(Guid userId, Guid otherUserId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = otherUserId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
await Assert.ThrowsAsync<NotFoundException>(() => _sut.PutRemoveAuth(sendId.ToString()));
await _sendRepository.Received(1).GetByIdAsync(sendId);
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithNullUserId_ThrowsInvalidOperationException(Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns((Guid?)null);
var exception =
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.PutRemoveAuth(sendId.ToString()));
Assert.Equal("User ID not found", exception.Message);
await _sendRepository.DidNotReceive().GetByIdAsync(Arg.Any<Guid>());
await _nonAnonymousSendCommand.DidNotReceive().SaveSendAsync(Arg.Any<Send>());
}
[Theory, AutoData]
public async Task PutRemoveAuth_WithSendHavingBothPasswordAndEmails_RemovesBoth(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password",
Emails = "test@example.com",
AuthType = AuthType.Password
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemoveAuth(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
Assert.Null(result.Password);
Assert.Null(result.Emails);
await _nonAnonymousSendCommand.Received(1).SaveSendAsync(Arg.Is<Send>(s =>
s.Id == sendId &&
s.Password == null &&
s.Emails == null &&
s.AuthType == AuthType.None));
}
[Theory, AutoData]
public async Task PutRemoveAuth_PreservesOtherSendProperties(Guid userId, Guid sendId)
{
_userService.GetProperUserId(Arg.Any<ClaimsPrincipal>()).Returns(userId);
var deletionDate = DateTime.UtcNow.AddDays(7);
var expirationDate = DateTime.UtcNow.AddDays(3);
var existingSend = new Send
{
Id = sendId,
UserId = userId,
Type = SendType.Text,
Data = JsonSerializer.Serialize(new SendTextData("Test", "Notes", "Text", false)),
Password = "hashed-password",
AuthType = AuthType.Password,
Key = "encryption-key",
MaxAccessCount = 10,
AccessCount = 3,
DeletionDate = deletionDate,
ExpirationDate = expirationDate,
Disabled = false,
HideEmail = true
};
_sendRepository.GetByIdAsync(sendId).Returns(existingSend);
var result = await _sut.PutRemoveAuth(sendId.ToString());
Assert.NotNull(result);
Assert.Equal(sendId, result.Id);
Assert.Equal(AuthType.None, result.AuthType);
// Verify other properties are preserved
Assert.Equal("encryption-key", result.Key);
Assert.Equal(10, result.MaxAccessCount);
Assert.Equal(3, result.AccessCount);
Assert.Equal(deletionDate, result.DeletionDate);
Assert.Equal(expirationDate, result.ExpirationDate);
Assert.False(result.Disabled);
Assert.True(result.HideEmail);
}
#endregion
#region Test Helpers
private static ClaimsPrincipal CreateUserWithSendIdClaim(Guid sendId)
{
var claims = new List<Claim> { new Claim("send_id", sendId.ToString()) };
var identity = new ClaimsIdentity(claims, "TestAuth");
return new ClaimsPrincipal(identity);
}
private static ControllerContext CreateControllerContextWithUser(ClaimsPrincipal user)
{
return new ControllerContext { HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext { User = user } };
}
#endregion
}

View File

@@ -26,6 +26,7 @@ public class SutProvider<TSut> : ISutProvider
public TSut Sut { get; private set; }
public Type SutType => typeof(TSut);
public IFixture Fixture => _fixture;
public SutProvider() : this(new Fixture()) { }
@@ -65,6 +66,19 @@ public class SutProvider<TSut> : ISutProvider
return this;
}
/// <summary>
/// Creates and registers a dependency to be injected when the sut is created.
/// </summary>
/// <typeparam name="TDep">The Dependency type to create</typeparam>
/// <param name="parameterName">The (optional) parameter name to register the dependency under</param>
/// <returns>The created dependency value</returns>
public TDep CreateDependency<TDep>(string parameterName = "")
{
var dependency = _fixture.Create<TDep>();
SetDependency(dependency, parameterName);
return dependency;
}
/// <summary>
/// Gets a dependency of the sut. Can only be called after the dependency has been set, either explicitly with
/// <see cref="SetDependency{T}"/> or automatically with <see cref="Create"/>.

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
<RootNamespace>Bit.Test.Common</RootNamespace>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,7 +1,10 @@
using System.Text.Json;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Billing.Organizations.Models;
using Bit.Test.Common.Helpers;
using Xunit;
@@ -115,4 +118,105 @@ public class OrganizationTests
Assert.True(organization.UseDisableSmAdsForUsers);
}
[Fact]
public void UpdateFromLicense_AppliesAllLicenseProperties()
{
// This test ensures that when a new property is added to OrganizationLicense,
// it is also applied to the Organization in UpdateFromLicense().
// This is the fourth step in the license synchronization pipeline:
// Property → Constant → Claim → Extraction → Application
// 1. Get all public properties from OrganizationLicense
var licenseProperties = typeof(OrganizationLicense)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.ToHashSet();
// 2. Define properties that don't need to be applied to Organization
var excludedProperties = new HashSet<string>
{
// Internal/computed properties
"SignatureBytes", // Computed from Signature property
"ValidLicenseVersion", // Internal property, not serialized
"CurrentLicenseFileVersion", // Constant field, not an instance property
"Hash", // Signature-related, not applied to org
"Signature", // Signature-related, not applied to org
"Token", // The JWT itself, not applied to org
"Version", // License version, not stored on org
// Properties intentionally excluded from UpdateFromLicense
"Id", // Self-hosted org has its own unique Guid
"MaxStorageGb", // Not enforced for self-hosted (per comment in UpdateFromLicense)
// Properties not stored on Organization model
"LicenseType", // Not a property on Organization
"InstallationId", // Not a property on Organization
"Issued", // Not a property on Organization
"Refresh", // Not a property on Organization
"ExpirationWithoutGracePeriod", // Not a property on Organization
"Trial", // Not a property on Organization
"Expires", // Mapped to ExpirationDate on Organization (different name)
// Deprecated properties not applied
"LimitCollectionCreationDeletion", // Deprecated, not applied
"AllowAdminAccessToAllCollectionItems", // Deprecated, not applied
};
// 3. Get properties that should be applied
var propertiesThatShouldBeApplied = licenseProperties
.Except(excludedProperties)
.ToHashSet();
// 4. Read Organization.UpdateFromLicense source code
var organizationSourcePath = Path.Combine(
Directory.GetCurrentDirectory(),
"..", "..", "..", "..", "..", "src", "Core", "AdminConsole", "Entities", "Organization.cs");
var sourceCode = File.ReadAllText(organizationSourcePath);
// 5. Find all property assignments in UpdateFromLicense method
// Pattern matches: PropertyName = license.PropertyName
// This regex looks for assignments like "Name = license.Name" or "ExpirationDate = license.Expires"
var assignmentPattern = @"(\w+)\s*=\s*license\.(\w+)";
var matches = Regex.Matches(sourceCode, assignmentPattern);
var appliedProperties = new HashSet<string>();
foreach (Match match in matches)
{
// Get the license property name (right side of assignment)
var licensePropertyName = match.Groups[2].Value;
appliedProperties.Add(licensePropertyName);
}
// Special case: Expires is mapped to ExpirationDate
if (appliedProperties.Contains("Expires"))
{
appliedProperties.Add("Expires"); // Already added, but being explicit
}
// 6. Find missing applications
var missingApplications = propertiesThatShouldBeApplied
.Except(appliedProperties)
.OrderBy(p => p)
.ToList();
// 7. Build error message with guidance
var errorMessage = "";
if (missingApplications.Any())
{
errorMessage = $"The following OrganizationLicense properties are NOT applied to Organization in UpdateFromLicense():\n";
errorMessage += string.Join("\n", missingApplications.Select(p => $" - {p}"));
errorMessage += "\n\nPlease add the following lines to Organization.UpdateFromLicense():\n";
foreach (var prop in missingApplications)
{
errorMessage += $" {prop} = license.{prop};\n";
}
errorMessage += "\nNote: If the property maps to a different name on Organization (like Expires → ExpirationDate), adjust accordingly.";
}
// 8. Assert - if this fails, the error message guides the developer to add the application
Assert.True(
!missingApplications.Any(),
$"\n{errorMessage}");
}
}

View File

@@ -283,7 +283,7 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited,
UserId = Guid.NewGuid(),
UserId = null,
Email = "invited@example.com"
};
@@ -302,6 +302,56 @@ public class AutomaticUserConfirmationPolicyEventHandlerTests
Assert.True(string.IsNullOrEmpty(result));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_MixedUsersWithNullUserId_HandlesCorrectly(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,
Guid confirmedUserId,
SutProvider<AutomaticUserConfirmationPolicyEventHandler> sutProvider)
{
// Arrange
var invitedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited,
UserId = null,
Email = "invited@example.com"
};
var confirmedUser = new OrganizationUserUserDetails
{
Id = Guid.NewGuid(),
OrganizationId = policyUpdate.OrganizationId,
Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Confirmed,
UserId = confirmedUserId,
Email = "confirmed@example.com"
};
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyDetailsByOrganizationAsync(policyUpdate.OrganizationId)
.Returns([invitedUser, confirmedUser]);
sutProvider.GetDependency<IOrganizationUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
sutProvider.GetDependency<IProviderUserRepository>()
.GetManyByManyUsersAsync(Arg.Any<IEnumerable<Guid>>())
.Returns([]);
// Act
var result = await sutProvider.Sut.ValidateAsync(policyUpdate, null);
// Assert
Assert.True(string.IsNullOrEmpty(result));
await sutProvider.GetDependency<IOrganizationUserRepository>()
.Received(1)
.GetManyByManyUsersAsync(Arg.Is<IEnumerable<Guid>>(ids => ids.Count() == 1 && ids.First() == confirmedUserId));
}
[Theory, BitAutoData]
public async Task ValidateAsync_EnablingPolicy_RevokedUsersIncluded_InComplianceCheck(
[PolicyUpdate(PolicyType.AutomaticUserConfirmation)] PolicyUpdate policyUpdate,

View File

@@ -0,0 +1,68 @@
using System.Reflection;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Organizations.Models;
using Xunit;
namespace Bit.Core.Test.Billing.Licenses;
public class LicenseConstantsTests
{
[Fact]
public void OrganizationLicenseConstants_HasConstantForEveryLicenseProperty()
{
// This test ensures that when a new property is added to OrganizationLicense,
// a corresponding constant is added to OrganizationLicenseConstants.
// This is the first step in the license synchronization pipeline:
// Property → Constant → Claim → Extraction → Application
// 1. Get all public properties from OrganizationLicense
var licenseProperties = typeof(OrganizationLicense)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.ToHashSet();
// 2. Get all constants from OrganizationLicenseConstants
var constants = typeof(OrganizationLicenseConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && !f.IsInitOnly)
.Select(f => f.GetValue(null) as string)
.ToHashSet();
// 3. Define properties that don't need constants (internal/computed/non-claims properties)
var excludedProperties = new HashSet<string>
{
"SignatureBytes", // Computed from Signature property
"ValidLicenseVersion", // Internal property, not serialized
"CurrentLicenseFileVersion", // Constant field, not an instance property
"Hash", // Signature-related, not in claims system
"Signature", // Signature-related, not in claims system
"Token", // The JWT itself, not a claim within the token
"Version" // Not in claims system (only in deprecated property-based licenses)
};
// 4. Find license properties without corresponding constants
var propertiesWithoutConstants = licenseProperties
.Except(constants)
.Except(excludedProperties)
.OrderBy(p => p)
.ToList();
// 5. Build error message with guidance
var errorMessage = "";
if (propertiesWithoutConstants.Any())
{
errorMessage = $"The following OrganizationLicense properties don't have constants in OrganizationLicenseConstants:\n";
errorMessage += string.Join("\n", propertiesWithoutConstants.Select(p => $" - {p}"));
errorMessage += "\n\nPlease add the following constants to OrganizationLicenseConstants:\n";
foreach (var prop in propertiesWithoutConstants)
{
errorMessage += $" public const string {prop} = nameof({prop});\n";
}
}
// 6. Assert - if this fails, the error message guides the developer to add the constant
Assert.True(
!propertiesWithoutConstants.Any(),
$"\n{errorMessage}");
}
}

View File

@@ -0,0 +1,92 @@
using System.Reflection;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Models;
using Bit.Core.Billing.Licenses.Services.Implementations;
using Bit.Core.Models.Business;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.Billing.Licenses.Services.Implementations;
public class OrganizationLicenseClaimsFactoryTests
{
[Theory, BitAutoData]
public async Task GenerateClaims_CreatesClaimsForAllConstants(Organization organization)
{
// This test ensures that when a constant is added to OrganizationLicenseConstants,
// it is also added to the OrganizationLicenseClaimsFactory to generate claims.
// This is the second step in the license synchronization pipeline:
// Property → Constant → Claim → Extraction → Application
// 1. Populate all nullable properties to ensure claims can be generated
// The factory only adds claims for properties that have values
organization.Name = "Test Organization";
organization.BillingEmail = "billing@test.com";
organization.BusinessName = "Test Business";
organization.Plan = "Enterprise";
organization.LicenseKey = "test-license-key";
organization.Seats = 100;
organization.MaxCollections = 50;
organization.MaxStorageGb = 10;
organization.SmSeats = 25;
organization.SmServiceAccounts = 10;
organization.ExpirationDate = DateTime.UtcNow.AddYears(1); // Ensure org is not expired
// Create a LicenseContext with a minimal SubscriptionInfo to trigger conditional claims
// ExpirationWithoutGracePeriod is only generated for active, non-trial, annual subscriptions
var licenseContext = new LicenseContext
{
InstallationId = Guid.NewGuid(),
SubscriptionInfo = new SubscriptionInfo
{
Subscription = new SubscriptionInfo.BillingSubscription(null!)
{
TrialEndDate = DateTime.UtcNow.AddDays(-30), // Trial ended in the past
PeriodStartDate = DateTime.UtcNow,
PeriodEndDate = DateTime.UtcNow.AddDays(365), // Annual subscription (>180 days)
Status = "active"
}
}
};
// 2. Generate claims
var factory = new OrganizationLicenseClaimsFactory();
var claims = await factory.GenerateClaims(organization, licenseContext);
// 3. Get all constants from OrganizationLicenseConstants
var allConstants = typeof(OrganizationLicenseConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.IsLiteral && !f.IsInitOnly)
.Select(f => f.GetValue(null) as string)
.ToHashSet();
// 4. Get claim types from generated claims
var generatedClaimTypes = claims.Select(c => c.Type).ToHashSet();
// 5. Find constants that don't have corresponding claims
var constantsWithoutClaims = allConstants
.Except(generatedClaimTypes)
.OrderBy(c => c)
.ToList();
// 6. Build error message with guidance
var errorMessage = "";
if (constantsWithoutClaims.Any())
{
errorMessage = $"The following constants in OrganizationLicenseConstants are NOT generated as claims in OrganizationLicenseClaimsFactory:\n";
errorMessage += string.Join("\n", constantsWithoutClaims.Select(c => $" - {c}"));
errorMessage += "\n\nPlease add the following claims to OrganizationLicenseClaimsFactory.GenerateClaims():\n";
foreach (var constant in constantsWithoutClaims)
{
errorMessage += $" new(nameof(OrganizationLicenseConstants.{constant}), entity.{constant}.ToString()),\n";
}
errorMessage += "\nNote: If the property is nullable, you may need to add it conditionally.";
}
// 7. Assert - if this fails, the error message guides the developer to add claim generation
Assert.True(
!constantsWithoutClaims.Any(),
$"\n{errorMessage}");
}
}

View File

@@ -1,9 +1,14 @@
using System.Security.Claims;
using System.Reflection;
using System.Security.Claims;
using System.Text.RegularExpressions;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Organizations.Commands;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -99,6 +104,320 @@ public class UpdateOrganizationLicenseCommandTests
}
}
[Theory, BitAutoData]
public async Task UpdateLicenseAsync_WithClaimsPrincipal_ExtractsAllPropertiesFromClaims(
SelfHostedOrganizationDetails selfHostedOrg,
OrganizationLicense license,
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
{
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
globalSettings.LicenseDirectory = LicenseDirectory;
globalSettings.SelfHosted = true;
// Setup license for CanUse validation
license.Enabled = true;
license.Issued = DateTime.Now.AddDays(-1);
license.Expires = DateTime.Now.AddDays(1);
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
license.InstallationId = globalSettings.Installation.Id;
license.LicenseType = LicenseType.Organization;
license.Token = "test-token"; // Indicates this is a claims-based license
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
// Create a ClaimsPrincipal with all organization license claims
var claims = new List<Claim>
{
new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()),
new(OrganizationLicenseConstants.InstallationId, globalSettings.Installation.Id.ToString()),
new(OrganizationLicenseConstants.Name, "Test Organization"),
new(OrganizationLicenseConstants.BillingEmail, "billing@test.com"),
new(OrganizationLicenseConstants.BusinessName, "Test Business"),
new(OrganizationLicenseConstants.PlanType, ((int)PlanType.EnterpriseAnnually).ToString()),
new(OrganizationLicenseConstants.Seats, "100"),
new(OrganizationLicenseConstants.MaxCollections, "50"),
new(OrganizationLicenseConstants.UsePolicies, "true"),
new(OrganizationLicenseConstants.UseSso, "true"),
new(OrganizationLicenseConstants.UseKeyConnector, "true"),
new(OrganizationLicenseConstants.UseScim, "true"),
new(OrganizationLicenseConstants.UseGroups, "true"),
new(OrganizationLicenseConstants.UseDirectory, "true"),
new(OrganizationLicenseConstants.UseEvents, "true"),
new(OrganizationLicenseConstants.UseTotp, "true"),
new(OrganizationLicenseConstants.Use2fa, "true"),
new(OrganizationLicenseConstants.UseApi, "true"),
new(OrganizationLicenseConstants.UseResetPassword, "true"),
new(OrganizationLicenseConstants.Plan, "Enterprise"),
new(OrganizationLicenseConstants.SelfHost, "true"),
new(OrganizationLicenseConstants.UsersGetPremium, "true"),
new(OrganizationLicenseConstants.UseCustomPermissions, "true"),
new(OrganizationLicenseConstants.Enabled, "true"),
new(OrganizationLicenseConstants.Expires, DateTime.Now.AddDays(1).ToString("O")),
new(OrganizationLicenseConstants.LicenseKey, "test-license-key"),
new(OrganizationLicenseConstants.UsePasswordManager, "true"),
new(OrganizationLicenseConstants.UseSecretsManager, "true"),
new(OrganizationLicenseConstants.SmSeats, "25"),
new(OrganizationLicenseConstants.SmServiceAccounts, "10"),
new(OrganizationLicenseConstants.UseRiskInsights, "true"),
new(OrganizationLicenseConstants.UseOrganizationDomains, "true"),
new(OrganizationLicenseConstants.UseAdminSponsoredFamilies, "true"),
new(OrganizationLicenseConstants.UseAutomaticUserConfirmation, "true"),
new(OrganizationLicenseConstants.UseDisableSmAdsForUsers, "true"),
new(OrganizationLicenseConstants.UsePhishingBlocker, "true"),
new(OrganizationLicenseConstants.MaxStorageGb, "5"),
new(OrganizationLicenseConstants.Issued, DateTime.Now.AddDays(-1).ToString("O")),
new(OrganizationLicenseConstants.Refresh, DateTime.Now.AddMonths(1).ToString("O")),
new(OrganizationLicenseConstants.ExpirationWithoutGracePeriod, DateTime.Now.AddMonths(12).ToString("O")),
new(OrganizationLicenseConstants.Trial, "false"),
new(OrganizationLicenseConstants.LimitCollectionCreationDeletion, "true"),
new(OrganizationLicenseConstants.AllowAdminAccessToAllCollectionItems, "true")
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
sutProvider.GetDependency<ILicensingService>()
.GetClaimsPrincipalFromLicense(license)
.Returns(claimsPrincipal);
// Setup selfHostedOrg for CanUseLicense validation
selfHostedOrg.OccupiedSeatCount = 50; // Less than the 100 seats in the license
selfHostedOrg.CollectionCount = 10; // Less than the 50 max collections in the license
selfHostedOrg.GroupCount = 1;
selfHostedOrg.UseGroups = true;
selfHostedOrg.UsePolicies = true;
selfHostedOrg.UseSso = true;
selfHostedOrg.UseKeyConnector = true;
selfHostedOrg.UseScim = true;
selfHostedOrg.UseCustomPermissions = true;
selfHostedOrg.UseResetPassword = true;
try
{
await sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null);
// Assertion: license file should be written to disk
var filePath = Path.Combine(LicenseDirectory, "organization", $"{selfHostedOrg.Id}.json");
await using var fs = File.OpenRead(filePath);
var licenseFromFile = await JsonSerializer.DeserializeAsync<OrganizationLicense>(fs);
AssertHelper.AssertPropertyEqual(license, licenseFromFile, "SignatureBytes");
// Assertion: organization should be updated with ALL properties extracted from claims
await sutProvider.GetDependency<IOrganizationService>()
.Received(1)
.ReplaceAndUpdateCacheAsync(Arg.Is<Organization>(org =>
org.Name == "Test Organization" &&
org.BillingEmail == "billing@test.com" &&
org.BusinessName == "Test Business" &&
org.PlanType == PlanType.EnterpriseAnnually &&
org.Seats == 100 &&
org.MaxCollections == 50 &&
org.UsePolicies == true &&
org.UseSso == true &&
org.UseKeyConnector == true &&
org.UseScim == true &&
org.UseGroups == true &&
org.UseDirectory == true &&
org.UseEvents == true &&
org.UseTotp == true &&
org.Use2fa == true &&
org.UseApi == true &&
org.UseResetPassword == true &&
org.Plan == "Enterprise" &&
org.SelfHost == true &&
org.UsersGetPremium == true &&
org.UseCustomPermissions == true &&
org.Enabled == true &&
org.LicenseKey == "test-license-key" &&
org.UsePasswordManager == true &&
org.UseSecretsManager == true &&
org.SmSeats == 25 &&
org.SmServiceAccounts == 10 &&
org.UseRiskInsights == true &&
org.UseOrganizationDomains == true &&
org.UseAdminSponsoredFamilies == true &&
org.UseAutomaticUserConfirmation == true &&
org.UseDisableSmAdsForUsers == true &&
org.UsePhishingBlocker == true));
}
finally
{
// Clean up temporary directory
if (Directory.Exists(OrganizationLicenseDirectory.Value))
{
Directory.Delete(OrganizationLicenseDirectory.Value, true);
}
}
}
[Theory, BitAutoData]
public async Task UpdateLicenseAsync_WrongInstallationIdInClaims_ThrowsBadRequestException(
SelfHostedOrganizationDetails selfHostedOrg,
OrganizationLicense license,
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
{
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
globalSettings.LicenseDirectory = LicenseDirectory;
globalSettings.SelfHosted = true;
// Setup license for CanUse validation
license.Enabled = true;
license.Issued = DateTime.Now.AddDays(-1);
license.Expires = DateTime.Now.AddDays(1);
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
license.LicenseType = LicenseType.Organization;
license.Token = "test-token"; // Indicates this is a claims-based license
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
// Create a ClaimsPrincipal with WRONG installation ID
var wrongInstallationId = Guid.NewGuid(); // Different from globalSettings.Installation.Id
var claims = new List<Claim>
{
new(OrganizationLicenseConstants.LicenseType, ((int)LicenseType.Organization).ToString()),
new(OrganizationLicenseConstants.InstallationId, wrongInstallationId.ToString()),
new(OrganizationLicenseConstants.Enabled, "true"),
new(OrganizationLicenseConstants.SelfHost, "true")
};
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity(claims));
sutProvider.GetDependency<ILicensingService>()
.GetClaimsPrincipalFromLicense(license)
.Returns(claimsPrincipal);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null));
Assert.Contains("The installation ID does not match the current installation.", exception.Message);
// Verify organization was NOT saved
await sutProvider.GetDependency<IOrganizationService>()
.DidNotReceive()
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
}
[Theory, BitAutoData]
public async Task UpdateLicenseAsync_ExpiredLicenseWithoutClaims_ThrowsBadRequestException(
SelfHostedOrganizationDetails selfHostedOrg,
OrganizationLicense license,
SutProvider<UpdateOrganizationLicenseCommand> sutProvider)
{
var globalSettings = sutProvider.GetDependency<IGlobalSettings>();
globalSettings.LicenseDirectory = LicenseDirectory;
globalSettings.SelfHosted = true;
// Setup legacy license (no Token, no claims)
license.Token = null; // Legacy license
license.Enabled = true;
license.Issued = DateTime.Now.AddDays(-2);
license.Expires = DateTime.Now.AddDays(-1); // Expired yesterday
license.Version = OrganizationLicense.CurrentLicenseFileVersion;
license.InstallationId = globalSettings.Installation.Id;
license.LicenseType = LicenseType.Organization;
license.SelfHost = true;
sutProvider.GetDependency<ILicensingService>().VerifyLicense(license).Returns(true);
sutProvider.GetDependency<ILicensingService>()
.GetClaimsPrincipalFromLicense(license)
.Returns((ClaimsPrincipal)null); // No claims for legacy license
// Passing values for SelfHostedOrganizationDetails.CanUseLicense
license.Seats = null;
license.MaxCollections = null;
license.UseGroups = true;
license.UsePolicies = true;
license.UseSso = true;
license.UseKeyConnector = true;
license.UseScim = true;
license.UseCustomPermissions = true;
license.UseResetPassword = true;
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.UpdateLicenseAsync(selfHostedOrg, license, null));
Assert.Contains("The license has expired.", exception.Message);
// Verify organization was NOT saved
await sutProvider.GetDependency<IOrganizationService>()
.DidNotReceive()
.ReplaceAndUpdateCacheAsync(Arg.Any<Organization>());
}
[Fact]
public async Task UpdateLicenseAsync_ExtractsAllClaimsBasedProperties_WhenClaimsPrincipalProvided()
{
// This test ensures that when new properties are added to OrganizationLicense,
// they are automatically extracted from JWT claims in UpdateOrganizationLicenseCommand.
// If a new constant is added to OrganizationLicenseConstants but not extracted,
// this test will fail with a clear message showing which properties are missing.
// 1. Get all OrganizationLicenseConstants
var constantFields = typeof(OrganizationLicenseConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.GetField)
.Where(f => f.IsLiteral && !f.IsInitOnly)
.Select(f => f.GetValue(null) as string)
.ToList();
// 2. Define properties that should be excluded (not claims-based or intentionally not extracted)
var excludedProperties = new HashSet<string>
{
"Version", // Not in claims system (only in deprecated property-based licenses)
"Hash", // Signature-related, not extracted from claims
"Signature", // Signature-related, not extracted from claims
"SignatureBytes", // Computed from Signature, not a claim
"Token", // The JWT itself, not extracted from claims
"Id" // Cloud org ID from license, not used - self-hosted org has its own separate ID
};
// 3. Get properties that should be extracted from claims
var propertiesThatShouldBeExtracted = constantFields
.Where(c => !excludedProperties.Contains(c))
.ToHashSet();
// 4. Read UpdateOrganizationLicenseCommand source code
var commandSourcePath = Path.Combine(
Directory.GetCurrentDirectory(),
"..", "..", "..", "..", "..",
"src", "Core", "Billing", "Organizations", "Commands", "UpdateOrganizationLicenseCommand.cs");
var sourceCode = await File.ReadAllTextAsync(commandSourcePath);
// 5. Find all GetValue calls that extract properties from claims
// Pattern matches: license.PropertyName = claimsPrincipal.GetValue<Type>(OrganizationLicenseConstants.PropertyName)
var extractedProperties = new HashSet<string>();
var getValuePattern = @"claimsPrincipal\.GetValue<[^>]+>\(OrganizationLicenseConstants\.(\w+)\)";
var matches = Regex.Matches(sourceCode, getValuePattern);
foreach (Match match in matches)
{
extractedProperties.Add(match.Groups[1].Value);
}
// 6. Find missing extractions
var missingExtractions = propertiesThatShouldBeExtracted
.Except(extractedProperties)
.OrderBy(p => p)
.ToList();
// 7. Build error message with guidance if there are missing extractions
var errorMessage = "";
if (missingExtractions.Any())
{
errorMessage = $"The following constants in OrganizationLicenseConstants are NOT extracted from claims in UpdateOrganizationLicenseCommand:\n";
errorMessage += string.Join("\n", missingExtractions.Select(p => $" - {p}"));
errorMessage += "\n\nPlease add the following lines to UpdateOrganizationLicenseCommand.cs in the 'if (claimsPrincipal != null)' block:\n";
foreach (var prop in missingExtractions)
{
errorMessage += $" license.{prop} = claimsPrincipal.GetValue<TYPE>(OrganizationLicenseConstants.{prop});\n";
}
}
// 8. Assert - if this fails, the error message guides the developer to add the extraction
// Note: We don't check for "extra extractions" because that would be a compile error
// (can't reference OrganizationLicenseConstants.Foo if Foo doesn't exist)
Assert.True(
!missingExtractions.Any(),
$"\n{errorMessage}");
}
// Wrapper to compare 2 objects that are different types
private bool AssertPropertyEqual(OrganizationLicense expected, Organization actual, params string[] excludedPropertyStrings)
{

View File

@@ -266,7 +266,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
await _stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
await _subscriberService.Received(1).CreateBraintreeCustomer(user, paymentMethod.Token);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
Arg.Is<InvoiceUpdateOptions>(opts =>
opts.AutoAdvance == false &&
opts.Expand != null &&
opts.Expand.Contains("customer")));
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
await _userService.Received(1).SaveUserAsync(user);
await _pushNotificationService.Received(1).PushSyncVaultAsync(user.Id);
@@ -502,7 +505,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.True(user.Premium);
Assert.Equal(mockSubscription.GetCurrentPeriodEnd(), user.PremiumExpirationDate);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
Arg.Is<InvoiceUpdateOptions>(opts =>
opts.AutoAdvance == false &&
opts.Expand != null &&
opts.Expand.Contains("customer")));
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
}
@@ -612,7 +618,10 @@ public class CreatePremiumCloudHostedSubscriptionCommandTests
Assert.False(user.Premium);
Assert.Null(user.PremiumExpirationDate);
await _stripeAdapter.Received(1).UpdateInvoiceAsync(mockSubscription.LatestInvoiceId,
Arg.Is<InvoiceUpdateOptions>(opts => opts.AutoAdvance == false));
Arg.Is<InvoiceUpdateOptions>(opts =>
opts.AutoAdvance == false &&
opts.Expand != null &&
opts.Expand.Contains("customer")));
await _braintreeService.Received(1).PayInvoice(Arg.Any<SubscriberId>(), mockInvoice);
}

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<IsPackable>false</IsPackable>
<RootNamespace>Bit.Core.Test</RootNamespace>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
@@ -30,7 +32,7 @@
<ItemGroup>
<!-- Email templates uses .hbs extension, they must be included for emails to work -->
<EmbeddedResource Include="**\*.hbs" />
<EmbeddedResource Include="Utilities\data\embeddedResource.txt" />
</ItemGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More