1
0
mirror of https://github.com/bitwarden/server.git synced 2025-05-25 05:21:03 -05:00

Merge remote-tracking branch 'origin/main' into make-client-retrieval-more-extensible

This commit is contained in:
Justin Baur 2025-04-29 10:19:41 -04:00
commit f05965529e
No known key found for this signature in database
135 changed files with 32266 additions and 392 deletions

View File

@ -9,6 +9,19 @@
"nuget", "nuget",
], ],
packageRules: [ packageRules: [
{
// Group all release-related workflows for GitHub Actions together for BRE.
groupName: "github-action",
matchManagers: ["github-actions"],
matchFileNames: [
".github/workflows/publish.yml",
".github/workflows/release.yml",
".github/workflows/repository-management.yml"
],
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
addLabels: ["hold"]
},
{ {
groupName: "dockerfile minor", groupName: "dockerfile minor",
matchManagers: ["dockerfile"], matchManagers: ["dockerfile"],

View File

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.4.1</Version> <Version>2025.4.3</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@ -110,9 +110,14 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
IEnumerable<string> organizationOwnerEmails) IEnumerable<string> organizationOwnerEmails)
{ {
if (provider.IsBillable() && if (provider.IsBillable() &&
organization.IsValidClient() && organization.IsValidClient())
!string.IsNullOrEmpty(organization.GatewayCustomerId))
{ {
// An organization converted to a business unit will not have a Customer since it was given to the business unit.
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
{
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
}
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{ {
Description = string.Empty, Description = string.Empty,

View File

@ -21,7 +21,6 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities;
using CsvHelper; using CsvHelper;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -46,7 +45,6 @@ public class ProviderBillingService(
[FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy) [FromKeyedServices(AutomaticTaxFactory.BusinessUse)] IAutomaticTaxStrategy automaticTaxStrategy)
: IProviderBillingService : IProviderBillingService
{ {
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization( public async Task AddExistingOrganization(
Provider provider, Provider provider,
Organization organization, Organization organization,
@ -312,7 +310,6 @@ public class ProviderBillingService(
return memoryStream.ToArray(); return memoryStream.ToArray();
} }
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations( public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
Provider provider, Provider provider,
Guid userId) Guid userId)

View File

@ -8,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context; using Bit.Scim.Context;
using Bit.Scim.Utilities; using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe; using Stripe;

View File

@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Scim.Context; using Bit.Scim.Context;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View File

@ -19,10 +19,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores; using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@ -7,9 +7,9 @@ using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Sso.Models; using Bit.Sso.Models;
using Bit.Sso.Utilities; using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure; using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;

View File

@ -441,9 +441,9 @@
} }
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -455,9 +455,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.5", "version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -687,9 +687,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -821,9 +821,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001700", "version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -975,9 +975,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.103", "version": "1.5.128",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz",
"integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==", "integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -1248,9 +1248,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.0.3", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1501,9 +1501,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.8", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2107,9 +2107,9 @@
} }
}, },
"node_modules/terser-webpack-plugin": { "node_modules/terser-webpack-plugin": {
"version": "5.3.11", "version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2163,9 +2163,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.2", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

0
dev/ef_migrate.ps1 Normal file → Executable file
View File

View File

@ -25,6 +25,12 @@
"Subscriptions": [ "Subscriptions": [
{ {
"Name": "events-write-subscription" "Name": "events-write-subscription"
},
{
"Name": "events-slack-subscription"
},
{
"Name": "events-webhook-subscription"
} }
] ]
} }

View File

@ -470,6 +470,19 @@ public class ProvidersController : Controller
[RequirePermission(Permission.Provider_Edit)] [RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> Delete(Guid id, string providerName) public async Task<IActionResult> Delete(Guid id, string providerName)
{ {
var provider = await _providerRepository.GetByIdAsync(id);
if (provider is null)
{
return BadRequest("Provider does not exist");
}
if (provider.Status == ProviderStatusType.Pending)
{
await _providerService.DeleteAsync(provider);
return NoContent();
}
if (string.IsNullOrWhiteSpace(providerName)) if (string.IsNullOrWhiteSpace(providerName))
{ {
return BadRequest("Invalid provider name"); return BadRequest("Invalid provider name");
@ -482,13 +495,6 @@ public class ProvidersController : Controller
return BadRequest("You must unlink all clients before you can delete a provider"); return BadRequest("You must unlink all clients before you can delete a provider");
} }
var provider = await _providerRepository.GetByIdAsync(id);
if (provider is null)
{
return BadRequest("Provider does not exist");
}
if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase)) if (!string.Equals(providerName.Trim(), provider.DisplayName(), StringComparison.OrdinalIgnoreCase))
{ {
return BadRequest("Invalid provider name"); return BadRequest("Invalid provider name");

View File

@ -183,6 +183,17 @@
<div class="p-3"> <div class="p-3">
<h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4> <h4 class="fw-bolder" id="exampleModalLabel">Delete provider</h4>
</div> </div>
@if (Model.Provider.Status == ProviderStatusType.Pending)
{
<div class="modal-body">
<span class="fw-light">
This action is permanent and irreversible.
</span>
</div>
}
else
{
<div class="modal-body"> <div class="modal-body">
<span class="fw-light"> <span class="fw-light">
This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data. This action is permanent and irreversible. Enter the provider name to complete deletion of the provider and associated data.
@ -194,6 +205,7 @@
</div> </div>
</form> </form>
</div> </div>
}
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-outline-primary btn-pill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button> <button type="button" class="btn btn-danger btn-pill" onclick="deleteProvider('@Model.Provider.Id');">Delete provider</button>

View File

@ -442,9 +442,9 @@
} }
}, },
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.6", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -456,9 +456,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.13.5", "version": "22.13.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.14.tgz",
"integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", "integrity": "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -688,9 +688,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.14.0", "version": "8.14.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
"integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
@ -822,9 +822,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001700", "version": "1.0.30001707",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001707.tgz",
"integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==", "integrity": "sha512-3qtRjw/HQSMlDWf+X79N206fepf4SOOU6SQLMaq/0KkZLmSjPxAkBOQQ+FxbHKfHmYLZFfdWsO3KA90ceHPSnw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -976,9 +976,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.103", "version": "1.5.128",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.128.tgz",
"integrity": "sha512-P6+XzIkfndgsrjROJWfSvVEgNHtPgbhVyTkwLjUM2HU/h7pZRORgaTlHqfAikqxKmdJMLW8fftrdGWbd/Ds0FA==", "integrity": "sha512-bo1A4HH/NS522Ws0QNFIzyPcyUUNV/yyy70Ho1xqfGYzPUme2F/xr4tlEOuM6/A538U1vDA7a4XfCd1CKRegKQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@ -1249,9 +1249,9 @@
} }
}, },
"node_modules/immutable": { "node_modules/immutable": {
"version": "5.0.3", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==", "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@ -1502,9 +1502,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.8", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -2108,9 +2108,9 @@
} }
}, },
"node_modules/terser-webpack-plugin": { "node_modules/terser-webpack-plugin": {
"version": "5.3.11", "version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.11.tgz", "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-RVCsMfuD0+cTt3EwX8hSl2Ks56EbFHWmhluwcqoPKtBnfjiT6olaq7PRIRfhyU8nnC2MrnDrBLfrD/RGE+cVXQ==", "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -2172,9 +2172,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.2", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==", "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {

View File

@ -0,0 +1,106 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller
{
[HttpPost("")]
public async Task<OrganizationIntegrationConfigurationResponseModel> CreateAsync(
Guid organizationId,
Guid integrationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
}
[HttpPut("{configurationId:guid}")]
public async Task<OrganizationIntegrationConfigurationResponseModel> UpdateAsync(
Guid organizationId,
Guid integrationId,
Guid configurationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
}
[HttpDelete("{configurationId:guid}")]
[HttpPost("{configurationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@ -0,0 +1,74 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations")]
[Authorize("Application")]
public class OrganizationIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository) : Controller
{
[HttpPost("")]
public async Task<OrganizationIntegrationResponseModel> CreateAsync(Guid organizationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
return new OrganizationIntegrationResponseModel(integration);
}
[HttpPut("{integrationId:guid}")]
public async Task<OrganizationIntegrationResponseModel> UpdateAsync(Guid organizationId, Guid integrationId, [FromBody] OrganizationIntegrationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
return new OrganizationIntegrationResponseModel(integration);
}
[HttpDelete("{integrationId:guid}")]
[HttpPost("{integrationId:guid}/delete")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@ -494,7 +494,7 @@ public class OrganizationUsersController : Controller
} }
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId); var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption; var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash)) if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
{ {
throw new BadRequestException("Incorrect password"); throw new BadRequestException("Incorrect password");

View File

@ -1,6 +1,5 @@
using Bit.Api.Billing.Controllers; using Bit.Api.Billing.Controllers;
using Bit.Api.Billing.Models.Requests; using Bit.Api.Billing.Models.Requests;
using Bit.Core;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
@ -17,7 +16,6 @@ namespace Bit.Api.AdminConsole.Controllers;
[Route("providers/{providerId:guid}/clients")] [Route("providers/{providerId:guid}/clients")]
public class ProviderClientsController( public class ProviderClientsController(
ICurrentContext currentContext, ICurrentContext currentContext,
IFeatureService featureService,
ILogger<BaseProviderController> logger, ILogger<BaseProviderController> logger,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IProviderBillingService providerBillingService, IProviderBillingService providerBillingService,
@ -140,11 +138,6 @@ public class ProviderClientsController(
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId) public async Task<IResult> GetAddableOrganizationsAsync([FromRoute] Guid providerId)
{ {
if (!featureService.IsEnabled(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal))
{
return Error.NotFound();
}
var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId); var (provider, result) = await TryGetBillableProviderForServiceUserOperation(providerId);
if (provider == null) if (provider == null)

View File

@ -0,0 +1,80 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[RequireFeature(FeatureFlagKeys.EventBasedOrganizationIntegrations)]
[Route("organizations/{organizationId:guid}/integrations/slack")]
[Authorize("Application")]
public class SlackIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
ISlackService slackService) : Controller
{
[HttpGet("redirect")]
public async Task<IActionResult> RedirectAsync(Guid organizationId)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
string callbackUrl = Url.RouteUrl(
nameof(CreateAsync),
new { organizationId },
currentContext.HttpContext.Request.Scheme);
var redirectUrl = slackService.GetRedirectUrl(callbackUrl);
if (string.IsNullOrEmpty(redirectUrl))
{
throw new NotFoundException();
}
return Redirect(redirectUrl);
}
[HttpGet("create", Name = nameof(CreateAsync))]
public async Task<IActionResult> CreateAsync(Guid organizationId, [FromQuery] string code)
{
if (!await currentContext.OrganizationOwner(organizationId))
{
throw new NotFoundException();
}
if (string.IsNullOrEmpty(code))
{
throw new BadRequestException("Missing code from Slack.");
}
string callbackUrl = Url.RouteUrl(
nameof(CreateAsync),
new { organizationId },
currentContext.HttpContext.Request.Scheme);
var token = await slackService.ObtainTokenViaOAuth(code, callbackUrl);
if (string.IsNullOrEmpty(token))
{
throw new BadRequestException("Invalid response from Slack.");
}
var integration = await integrationRepository.CreateAsync(new OrganizationIntegration
{
OrganizationId = organizationId,
Type = IntegrationType.Slack,
Configuration = JsonSerializer.Serialize(new SlackIntegration(token)),
});
var location = $"/organizations/{organizationId}/integrations/{integration.Id}";
return Created(location, new OrganizationIntegrationResponseModel(integration));
}
}

View File

@ -0,0 +1,73 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationConfigurationRequestModel
{
public string? Configuration { get; set; }
[Required]
public EventType EventType { get; set; }
public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType)
{
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<SlackIntegrationConfiguration>();
case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) && IsConfigurationValid<WebhookIntegrationConfiguration>();
default:
return false;
}
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId)
{
return new OrganizationIntegrationConfiguration()
{
OrganizationIntegrationId = organizationIntegrationId,
Configuration = Configuration,
EventType = EventType,
Template = Template
};
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration)
{
currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType;
currentConfiguration.Template = Template;
return currentConfiguration;
}
private bool IsConfigurationValid<T>()
{
if (string.IsNullOrWhiteSpace(Configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(Configuration);
return config is not null;
}
catch
{
return false;
}
}
}

View File

@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationRequestModel : IValidatableObject
{
public string? Configuration { get; set; }
public IntegrationType Type { get; set; }
public OrganizationIntegration ToOrganizationIntegration(Guid organizationId)
{
return new OrganizationIntegration()
{
OrganizationId = organizationId,
Configuration = Configuration,
Type = Type,
};
}
public OrganizationIntegration ToOrganizationIntegration(OrganizationIntegration currentIntegration)
{
currentIntegration.Configuration = Configuration;
return currentIntegration;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
switch (Type)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
yield return new ValidationResult($"{nameof(Type)} integrations are not yet supported.", new[] { nameof(Type) });
break;
case IntegrationType.Slack:
yield return new ValidationResult($"{nameof(Type)} integrations cannot be created directly.", new[] { nameof(Type) });
break;
case IntegrationType.Webhook:
if (Configuration is not null)
{
yield return new ValidationResult(
"Webhook integrations must not include configuration.",
new[] { nameof(Configuration) });
}
break;
default:
yield return new ValidationResult(
$"Integration type '{Type}' is not recognized.",
new[] { nameof(Type) });
break;
}
}
}

View File

@ -0,0 +1,28 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationConfigurationResponseModel : ResponseModel
{
public OrganizationIntegrationConfigurationResponseModel(OrganizationIntegrationConfiguration organizationIntegrationConfiguration, string obj = "organizationIntegrationConfiguration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegrationConfiguration);
Id = organizationIntegrationConfiguration.Id;
Configuration = organizationIntegrationConfiguration.Configuration;
CreationDate = organizationIntegrationConfiguration.CreationDate;
EventType = organizationIntegrationConfiguration.EventType;
Template = organizationIntegrationConfiguration.Template;
}
public Guid Id { get; set; }
public string? Configuration { get; set; }
public DateTime CreationDate { get; set; }
public EventType EventType { get; set; }
public string? Template { get; set; }
}

View File

@ -0,0 +1,22 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
#nullable enable
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationIntegrationResponseModel : ResponseModel
{
public OrganizationIntegrationResponseModel(OrganizationIntegration organizationIntegration, string obj = "organizationIntegration")
: base(obj)
{
ArgumentNullException.ThrowIfNull(organizationIntegration);
Id = organizationIntegration.Id;
Type = organizationIntegration.Type;
}
public Guid Id { get; set; }
public IntegrationType Type { get; set; }
}

View File

@ -221,8 +221,7 @@ public class MembersController : Controller
/// Remove a member. /// Remove a member.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Permanently removes a member from the organization. This cannot be undone. /// Removes a member from the organization. This cannot be undone. The user account will still remain.
/// The user account will still remain. The user is only removed from the organization.
/// </remarks> /// </remarks>
/// <param name="id">The identifier of the member to be removed.</param> /// <param name="id">The identifier of the member to be removed.</param>
[HttpDelete("{id}")] [HttpDelete("{id}")]

View File

@ -4,6 +4,7 @@ using Bit.Api.Auth.Models.Response.WebAuthn;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -31,6 +32,8 @@ public class WebAuthnController : Controller
private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand; private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand; private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand; private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
public WebAuthnController( public WebAuthnController(
IUserService userService, IUserService userService,
@ -41,7 +44,9 @@ public class WebAuthnController : Controller
IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand, IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand,
ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand, ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand, IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand) IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
_policyService = policyService; _policyService = policyService;
@ -52,7 +57,8 @@ public class WebAuthnController : Controller
_createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand; _createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand; _getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
} }
[HttpGet("")] [HttpGet("")]
@ -68,7 +74,7 @@ public class WebAuthnController : Controller
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model) public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
{ {
var user = await VerifyUserAsync(model); var user = await VerifyUserAsync(model);
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); await ValidateIfUserCanUsePasskeyLogin(user.Id);
var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user); var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options); var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options);
@ -101,7 +107,7 @@ public class WebAuthnController : Controller
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model) public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
{ {
var user = await GetUserAsync(); var user = await GetUserAsync();
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id); await ValidateIfUserCanUsePasskeyLogin(user.Id);
var tokenable = _createOptionsDataProtector.Unprotect(model.Token); var tokenable = _createOptionsDataProtector.Unprotect(model.Token);
if (!tokenable.TokenIsValid(user)) if (!tokenable.TokenIsValid(user))
@ -126,6 +132,22 @@ public class WebAuthnController : Controller
} }
} }
private async Task ValidateIfUserCanUsePasskeyLogin(Guid userId)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements))
{
await ValidateRequireSsoPolicyDisabledOrNotApplicable(userId);
return;
}
var requireSsoPolicyRequirement = await _policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(userId);
if (!requireSsoPolicyRequirement.CanUsePasskeyLogin)
{
throw new BadRequestException("Passkeys cannot be created for your account. SSO login is required.");
}
}
[HttpPut()] [HttpPut()]
public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model) public async Task UpdateCredential([FromBody] WebAuthnLoginCredentialUpdateRequestModel model)
{ {

View File

@ -86,9 +86,9 @@ public class OrganizationSponsorshipsController : Controller
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{ {
if (model.SponsoringUserId.HasValue) if (model.IsAdminInitiated.GetValueOrDefault())
{ {
throw new NotFoundException(); throw new BadRequestException();
} }
if (!string.IsNullOrWhiteSpace(model.Notes)) if (!string.IsNullOrWhiteSpace(model.Notes))
@ -97,13 +97,13 @@ public class OrganizationSponsorshipsController : Controller
} }
} }
var targetUser = model.SponsoringUserId ?? _currentContext.UserId!.Value;
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg, sponsoringOrg,
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
model.PlanSponsorshipType, model.PlanSponsorshipType,
model.SponsoredEmail, model.SponsoredEmail,
model.FriendlyName, model.FriendlyName,
model.IsAdminInitiated.GetValueOrDefault(),
model.Notes); model.Notes);
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name); await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
} }

View File

@ -128,6 +128,7 @@ public class DevicesController : Controller
} }
[HttpPost("{identifier}/retrieve-keys")] [HttpPost("{identifier}/retrieve-keys")]
[Obsolete("This endpoint is deprecated. The keys are on the regular device GET endpoints now.")]
public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier) public async Task<ProtectedDeviceResponseModel> GetDeviceKeys(string identifier)
{ {
var user = await _userService.GetUserByPrincipalAsync(User); var user = await _userService.GetUserByPrincipalAsync(User);

View File

@ -47,9 +47,9 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
{ {
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{ {
if (model.SponsoringUserId.HasValue) if (model.IsAdminInitiated.GetValueOrDefault())
{ {
throw new NotFoundException(); throw new BadRequestException();
} }
if (!string.IsNullOrWhiteSpace(model.Notes)) if (!string.IsNullOrWhiteSpace(model.Notes))
@ -60,8 +60,12 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
await _offerSponsorshipCommand.CreateSponsorshipAsync( await _offerSponsorshipCommand.CreateSponsorshipAsync(
await _organizationRepository.GetByIdAsync(sponsoringOrgId), await _organizationRepository.GetByIdAsync(sponsoringOrgId),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, model.SponsoringUserId ?? _currentContext.UserId ?? default), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, model.Notes); model.PlanSponsorshipType,
model.SponsoredEmail,
model.FriendlyName,
model.IsAdminInitiated.GetValueOrDefault(),
model.Notes);
} }
[HttpDelete("{sponsoringOrgId}")] [HttpDelete("{sponsoringOrgId}")]

View File

@ -17,11 +17,7 @@ public class OrganizationSponsorshipCreateRequestModel
[StringLength(256)] [StringLength(256)]
public string FriendlyName { get; set; } public string FriendlyName { get; set; }
/// <summary> public bool? IsAdminInitiated { get; set; }
/// (optional) The user to target for the sponsorship.
/// </summary>
/// <remarks>Left empty when creating a sponsorship for the authenticated user.</remarks>
public Guid? SponsoringUserId { get; set; }
[EncryptedString] [EncryptedString]
[EncryptedStringLength(512)] [EncryptedStringLength(512)]

View File

@ -2,6 +2,7 @@
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Api.Models.Response; namespace Bit.Api.Models.Response;
@ -21,6 +22,8 @@ public class DeviceResponseModel : ResponseModel
Identifier = device.Identifier; Identifier = device.Identifier;
CreationDate = device.CreationDate; CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted(); IsTrusted = device.IsTrusted();
EncryptedUserKey = device.EncryptedUserKey;
EncryptedPublicKey = device.EncryptedPublicKey;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@ -29,4 +32,10 @@ public class DeviceResponseModel : ResponseModel
public string Identifier { get; set; } public string Identifier { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public bool IsTrusted { get; set; } public bool IsTrusted { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedUserKey { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedPublicKey { get; set; }
} }

View File

@ -5,7 +5,7 @@ using Bit.Core.Settings;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Stripe; using Stripe;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Duende.IdentityModel; using IdentityModel;
using System.Globalization; using System.Globalization;
using Bit.Api.AdminConsole.Models.Request.Organizations; using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
@ -27,8 +27,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Tools.Entities; using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities; using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures; using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures; using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request; using Bit.Core.Auth.Models.Api.Request;
@ -215,6 +217,19 @@ public class Startup
{ {
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>(); services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
} }
// Slack
if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
} }
public void Configure( public void Configure(

View File

@ -1,5 +1,7 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Services; using Bit.Core.Services;
@ -15,19 +17,22 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
private readonly IStripeFacade _stripeFacade; private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
public PaymentMethodAttachedHandler( public PaymentMethodAttachedHandler(
ILogger<PaymentMethodAttachedHandler> logger, ILogger<PaymentMethodAttachedHandler> logger,
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IStripeFacade stripeFacade, IStripeFacade stripeFacade,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IFeatureService featureService) IFeatureService featureService,
IProviderRepository providerRepository)
{ {
_logger = logger; _logger = logger;
_stripeEventService = stripeEventService; _stripeEventService = stripeEventService;
_stripeFacade = stripeFacade; _stripeFacade = stripeFacade;
_stripeEventUtilityService = stripeEventUtilityService; _stripeEventUtilityService = stripeEventUtilityService;
_featureService = featureService; _featureService = featureService;
_providerRepository = providerRepository;
} }
public async Task HandleAsync(Event parsedEvent) public async Task HandleAsync(Event parsedEvent)
@ -68,7 +73,13 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
* If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved, * If we have an invoiced provider subscription where the customer hasn't been marked as invoice-approved,
* we need to try and set the default payment method and update the collection method to be "charge_automatically". * we need to try and set the default payment method and update the collection method to be "charge_automatically".
*/ */
if (invoicedProviderSubscription != null && !customer.ApprovedToPayByInvoice()) if (invoicedProviderSubscription != null &&
!customer.ApprovedToPayByInvoice() &&
Guid.TryParse(invoicedProviderSubscription.Metadata[StripeConstants.MetadataKeys.ProviderId], out var providerId))
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider is { Type: ProviderType.Msp })
{ {
if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id) if (customer.InvoiceSettings.DefaultPaymentMethodId != paymentMethod.Id)
{ {
@ -106,6 +117,7 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
customer.Id); customer.Id);
} }
} }
}
var unpaidSubscriptions = subscriptions?.Data.Where(subscription => var unpaidSubscriptions = subscriptions?.Data.Where(subscription =>
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList(); subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList();

View File

@ -2,6 +2,8 @@
public enum IntegrationType : int public enum IntegrationType : int
{ {
Slack = 1, CloudBillingSync = 1,
Webhook = 2, Scim = 2,
Slack = 3,
Webhook = 4,
} }

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegration(string token);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegrationConfiguration(string channelId);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record SlackIntegrationConfigurationDetails(string channelId, string token);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfiguration(string url);

View File

@ -0,0 +1,3 @@
namespace Bit.Core.Models.Data.Integrations;
public record WebhookIntegrationConfigurationDetils(string url);

View File

@ -0,0 +1,57 @@

using System.Text.Json.Serialization;
namespace Bit.Core.Models.Slack;
public abstract class SlackApiResponse
{
public bool Ok { get; set; }
[JsonPropertyName("response_metadata")]
public SlackResponseMetadata ResponseMetadata { get; set; } = new();
public string Error { get; set; } = string.Empty;
}
public class SlackResponseMetadata
{
[JsonPropertyName("next_cursor")]
public string NextCursor { get; set; } = string.Empty;
}
public class SlackChannelListResponse : SlackApiResponse
{
public List<SlackChannel> Channels { get; set; } = new();
}
public class SlackUserResponse : SlackApiResponse
{
public SlackUser User { get; set; } = new();
}
public class SlackOAuthResponse : SlackApiResponse
{
[JsonPropertyName("access_token")]
public string AccessToken { get; set; } = string.Empty;
public SlackTeam Team { get; set; } = new();
}
public class SlackTeam
{
public string Id { get; set; } = string.Empty;
}
public class SlackChannel
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class SlackUser
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class SlackDmResponse : SlackApiResponse
{
public SlackChannel Channel { get; set; } = new();
}

View File

@ -159,13 +159,13 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 })
{ {
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. await paymentService.AdjustSeatsAsync(organization,
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; validatedResult.Value.InviteOrganization.Plan,
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
@ -274,12 +274,11 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 })
{ {
return; await paymentService.AdjustSeatsAsync(organization,
} validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value);
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal; organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
@ -295,4 +294,5 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
}); });
} }
}
} }

View File

@ -44,7 +44,7 @@ public class InviteUsersPasswordManagerValidator(
return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate)); return new Invalid<PasswordManagerSubscriptionUpdate>(new PasswordManagerMustHaveSeatsError(subscriptionUpdate));
} }
if (subscriptionUpdate.MaxSeatsReached) if (subscriptionUpdate.MaxSeatsExceeded)
{ {
return new Invalid<PasswordManagerSubscriptionUpdate>( return new Invalid<PasswordManagerSubscriptionUpdate>(
new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate)); new PasswordManagerSeatLimitHasBeenReachedError(subscriptionUpdate));

View File

@ -48,6 +48,11 @@ public class PasswordManagerSubscriptionUpdate
/// </summary> /// </summary>
public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value; public bool MaxSeatsReached => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value >= MaxAutoScaleSeats.Value;
/// <summary>
/// If the new seat total exceeds the organization's auto-scale seat limit
/// </summary>
public bool MaxSeatsExceeded => UpdatedSeatTotal.HasValue && MaxAutoScaleSeats.HasValue && UpdatedSeatTotal.Value > MaxAutoScaleSeats.Value;
public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; } public Plan.PasswordManagerPlanFeatures PasswordManagerPlan { get; }
public InviteOrganization InviteOrganization { get; } public InviteOrganization InviteOrganization { get; }

View File

@ -0,0 +1,62 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.Enums;
using Bit.Core.Settings;
/// <summary>
/// Policy requirements for the Require SSO policy.
/// </summary>
public class RequireSsoPolicyRequirement : IPolicyRequirement
{
/// <summary>
/// Indicates whether the user can use passkey login.
/// </summary>
/// <remarks>
/// The user can use passkey login if they are not a member (Accepted/Confirmed) of an organization
/// that has the Require SSO policy enabled.
/// </remarks>
public bool CanUsePasskeyLogin { get; init; }
/// <summary>
/// Indicates whether SSO requirement is enforced for the user.
/// </summary>
/// <remarks>
/// The user is required to login with SSO if they are a confirmed member of an organization
/// that has the Require SSO policy enabled.
/// </remarks>
public bool SsoRequired { get; init; }
}
public class RequireSsoPolicyRequirementFactory : BasePolicyRequirementFactory<RequireSsoPolicyRequirement>
{
private readonly GlobalSettings _globalSettings;
public RequireSsoPolicyRequirementFactory(GlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public override PolicyType PolicyType => PolicyType.RequireSso;
protected override IEnumerable<OrganizationUserType> ExemptRoles =>
_globalSettings.Sso.EnforceSsoPolicyForAllUsers
? Array.Empty<OrganizationUserType>()
: [OrganizationUserType.Owner, OrganizationUserType.Admin];
public override RequireSsoPolicyRequirement Create(IEnumerable<PolicyDetails> policyDetails)
{
var result = new RequireSsoPolicyRequirement
{
CanUsePasskeyLogin = policyDetails.All(p =>
p.OrganizationUserStatus == OrganizationUserStatusType.Revoked ||
p.OrganizationUserStatus == OrganizationUserStatusType.Invited),
SsoRequired = policyDetails.Any(p =>
p.OrganizationUserStatus == OrganizationUserStatusType.Confirmed)
};
return result;
}
}

View File

@ -35,5 +35,6 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
} }
} }

View File

@ -18,6 +18,15 @@ public interface IOrganizationUserRepository : IRepository<OrganizationUser, Gui
Task<ICollection<OrganizationUser>> GetManyByUserAsync(Guid userId); Task<ICollection<OrganizationUser>> GetManyByUserAsync(Guid userId);
Task<ICollection<OrganizationUser>> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type); Task<ICollection<OrganizationUser>> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type);
Task<int> GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers); Task<int> GetCountByOrganizationAsync(Guid organizationId, string email, bool onlyRegisteredUsers);
/// <summary>
/// Returns the number of occupied seats for an organization.
/// Occupied seats are OrganizationUsers that have at least been invited.
/// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
/// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
/// </summary>
/// <param name="organizationId">The ID of the organization to get the occupied seat count for.</param>
/// <returns>The number of occupied seats for the organization.</returns>
Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId); Task<int> GetOccupiedSeatCountByOrganizationIdAsync(Guid organizationId);
Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers); Task<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers);
Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId); Task<OrganizationUser?> GetByOrganizationAsync(Guid organizationId, Guid userId);

View File

@ -0,0 +1,11 @@
namespace Bit.Core.Services;
public interface ISlackService
{
Task<string> GetChannelIdAsync(string token, string channelName);
Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames);
Task<string> GetDmChannelByEmailAsync(string token, string email);
string GetRedirectUrl(string redirectUrl);
Task<string> ObtainTokenViaOAuth(string code, string redirectUrl);
Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId);
}

View File

@ -0,0 +1,46 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
namespace Bit.Core.Services;
public class SlackEventHandler(
IOrganizationIntegrationConfigurationRepository configurationRepository,
ISlackService slackService)
: IEventMessageHandler
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
IntegrationType.Slack,
eventMessage.Type);
foreach (var configuration in configurations)
{
var config = configuration.MergedConfiguration.Deserialize<SlackIntegrationConfigurationDetails>();
if (config is null)
{
continue;
}
await slackService.SendSlackMessageByChannelIdAsync(
config.token,
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
config.channelId
);
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
foreach (var eventMessage in eventMessages)
{
await HandleEventAsync(eventMessage);
}
}
}

View File

@ -0,0 +1,162 @@
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Web;
using Bit.Core.Models.Slack;
using Bit.Core.Settings;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class SlackService(
IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings,
ILogger<SlackService> logger) : ISlackService
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _clientId = globalSettings.Slack.ClientId;
private readonly string _clientSecret = globalSettings.Slack.ClientSecret;
private readonly string _scopes = globalSettings.Slack.Scopes;
private readonly string _slackApiBaseUrl = globalSettings.Slack.ApiBaseUrl;
public const string HttpClientName = "SlackServiceHttpClient";
public async Task<string> GetChannelIdAsync(string token, string channelName)
{
return (await GetChannelIdsAsync(token, [channelName])).FirstOrDefault();
}
public async Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{
var matchingChannelIds = new List<string>();
var baseUrl = $"{_slackApiBaseUrl}/conversations.list";
var nextCursor = string.Empty;
do
{
var uriBuilder = new UriBuilder(baseUrl);
var queryParameters = HttpUtility.ParseQueryString(uriBuilder.Query);
queryParameters["types"] = "public_channel,private_channel";
queryParameters["limit"] = "1000";
if (!string.IsNullOrEmpty(nextCursor))
{
queryParameters["cursor"] = nextCursor;
}
uriBuilder.Query = queryParameters.ToString();
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.Uri);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackChannelListResponse>();
if (result is { Ok: true })
{
matchingChannelIds.AddRange(result.Channels
.Where(channel => channelNames.Contains(channel.Name))
.Select(channel => channel.Id));
nextCursor = result.ResponseMetadata.NextCursor;
}
else
{
logger.LogError("Error getting Channel Ids: {Error}", result.Error);
nextCursor = string.Empty;
}
} while (!string.IsNullOrEmpty(nextCursor));
return matchingChannelIds;
}
public async Task<string> GetDmChannelByEmailAsync(string token, string email)
{
var userId = await GetUserIdByEmailAsync(token, email);
return await OpenDmChannel(token, userId);
}
public string GetRedirectUrl(string redirectUrl)
{
return $"https://slack.com/oauth/v2/authorize?client_id={_clientId}&scope={_scopes}&redirect_uri={redirectUrl}";
}
public async Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
var tokenResponse = await _httpClient.PostAsync($"{_slackApiBaseUrl}/oauth.v2.access",
new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("client_id", _clientId),
new KeyValuePair<string, string>("client_secret", _clientSecret),
new KeyValuePair<string, string>("code", code),
new KeyValuePair<string, string>("redirect_uri", redirectUrl)
}));
SlackOAuthResponse result;
try
{
result = await tokenResponse.Content.ReadFromJsonAsync<SlackOAuthResponse>();
}
catch
{
result = null;
}
if (result == null)
{
logger.LogError("Error obtaining token via OAuth: Unknown error");
return string.Empty;
}
if (!result.Ok)
{
logger.LogError("Error obtaining token via OAuth: {Error}", result.Error);
return string.Empty;
}
return result.AccessToken;
}
public async Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
{
var payload = JsonContent.Create(new { channel = channelId, text = message });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/chat.postMessage");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
await _httpClient.SendAsync(request);
}
private async Task<string> GetUserIdByEmailAsync(string token, string email)
{
var request = new HttpRequestMessage(HttpMethod.Get, $"{_slackApiBaseUrl}/users.lookupByEmail?email={email}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackUserResponse>();
if (!result.Ok)
{
logger.LogError("Error retrieving Slack user ID: {Error}", result.Error);
return string.Empty;
}
return result.User.Id;
}
private async Task<string> OpenDmChannel(string token, string userId)
{
if (string.IsNullOrEmpty(userId))
return string.Empty;
var payload = JsonContent.Create(new { users = userId });
var request = new HttpRequestMessage(HttpMethod.Post, $"{_slackApiBaseUrl}/conversations.open");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Content = payload;
var response = await _httpClient.SendAsync(request);
var result = await response.Content.ReadFromJsonAsync<SlackDmResponse>();
if (!result.Ok)
{
logger.LogError("Error opening DM channel: {Error}", result.Error);
return string.Empty;
}
return result.Channel.Id;
}
}

View File

@ -1,30 +1,57 @@
using System.Net.Http.Json; using System.Text;
using System.Text.Json;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Settings; using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
namespace Bit.Core.Services; namespace Bit.Core.Services;
public class WebhookEventHandler( public class WebhookEventHandler(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings) IOrganizationIntegrationConfigurationRepository configurationRepository)
: IEventMessageHandler : IEventMessageHandler
{ {
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl;
public const string HttpClientName = "WebhookEventHandlerHttpClient"; public const string HttpClientName = "WebhookEventHandlerHttpClient";
public async Task HandleEventAsync(EventMessage eventMessage) public async Task HandleEventAsync(EventMessage eventMessage)
{ {
var content = JsonContent.Create(eventMessage); var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var response = await _httpClient.PostAsync(_webhookUrl, content); var configurations = await configurationRepository.GetConfigurationDetailsAsync(
organizationId,
IntegrationType.Webhook,
eventMessage.Type);
foreach (var configuration in configurations)
{
var config = configuration.MergedConfiguration.Deserialize<WebhookIntegrationConfigurationDetils>();
if (config is null || string.IsNullOrEmpty(config.url))
{
continue;
}
var content = new StringContent(
IntegrationTemplateProcessor.ReplaceTokens(configuration.Template, eventMessage),
Encoding.UTF8,
"application/json"
);
var response = await _httpClient.PostAsync(
config.url,
content);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
} }
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages) public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{ {
var content = JsonContent.Create(eventMessages); foreach (var eventMessage in eventMessages)
var response = await _httpClient.PostAsync(_webhookUrl, content); {
response.EnsureSuccessStatusCode(); await HandleEventAsync(eventMessage);
}
} }
} }

View File

@ -0,0 +1,36 @@
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.Services.NoopImplementations;
public class NoopSlackService : ISlackService
{
public Task<string> GetChannelIdAsync(string token, string channelName)
{
return Task.FromResult(string.Empty);
}
public Task<List<string>> GetChannelIdsAsync(string token, List<string> channelNames)
{
return Task.FromResult(new List<string>());
}
public Task<string> GetDmChannelByEmailAsync(string token, string email)
{
return Task.FromResult(string.Empty);
}
public string GetRedirectUrl(string redirectUrl)
{
return string.Empty;
}
public Task SendSlackMessageByChannelIdAsync(string token, string message, string channelId)
{
return Task.FromResult(0);
}
public Task<string> ObtainTokenViaOAuth(string code, string redirectUrl)
{
return Task.FromResult(string.Empty);
}
}

View File

@ -0,0 +1,23 @@
using System.Text.RegularExpressions;
namespace Bit.Core.AdminConsole.Utilities;
public static partial class IntegrationTemplateProcessor
{
[GeneratedRegex(@"#(\w+)#")]
private static partial Regex TokenRegex();
public static string ReplaceTokens(string template, object values)
{
if (string.IsNullOrEmpty(template) || values == null)
return template;
var type = values.GetType();
return TokenRegex().Replace(template, match =>
{
var propertyName = match.Groups[1].Value;
var property = type.GetProperty(propertyName);
return property?.GetValue(values)?.ToString() ?? match.Value;
});
}
}

View File

@ -1,6 +1,7 @@
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Response; namespace Bit.Core.Auth.Models.Api.Response;
@ -19,6 +20,8 @@ public class DeviceAuthRequestResponseModel : ResponseModel
Identifier = deviceAuthDetails.Identifier, Identifier = deviceAuthDetails.Identifier,
CreationDate = deviceAuthDetails.CreationDate, CreationDate = deviceAuthDetails.CreationDate,
IsTrusted = deviceAuthDetails.IsTrusted, IsTrusted = deviceAuthDetails.IsTrusted,
EncryptedPublicKey = deviceAuthDetails.EncryptedPublicKey,
EncryptedUserKey = deviceAuthDetails.EncryptedUserKey
}; };
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null) if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
@ -39,6 +42,12 @@ public class DeviceAuthRequestResponseModel : ResponseModel
public string Identifier { get; set; } public string Identifier { get; set; }
public DateTime CreationDate { get; set; } public DateTime CreationDate { get; set; }
public bool IsTrusted { get; set; } public bool IsTrusted { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedUserKey { get; set; }
[EncryptedString]
[EncryptedStringLength(2000)]
public string EncryptedPublicKey { get; set; }
public PendingAuthRequest DevicePendingAuthRequest { get; set; } public PendingAuthRequest DevicePendingAuthRequest { get; set; }

View File

@ -29,6 +29,8 @@ public class DeviceAuthDetails : Device
Identifier = device.Identifier; Identifier = device.Identifier;
CreationDate = device.CreationDate; CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted(); IsTrusted = device.IsTrusted();
EncryptedPublicKey = device.EncryptedPublicKey;
EncryptedUserKey = device.EncryptedUserKey;
AuthRequestId = authRequestId; AuthRequestId = authRequestId;
AuthRequestCreatedAt = authRequestCreationDate; AuthRequestCreatedAt = authRequestCreationDate;
} }
@ -74,6 +76,8 @@ public class DeviceAuthDetails : Device
EncryptedPrivateKey = encryptedPrivateKey, EncryptedPrivateKey = encryptedPrivateKey,
Active = active Active = active
}.IsTrusted(); }.IsTrusted();
EncryptedPublicKey = encryptedPublicKey;
EncryptedUserKey = encryptedUserKey;
AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null; AuthRequestId = authRequestId != Guid.Empty ? authRequestId : null;
AuthRequestCreatedAt = AuthRequestCreatedAt =
authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null; authRequestCreationDate != DateTime.MinValue ? authRequestCreationDate : null;

View File

@ -41,6 +41,7 @@ public static class OrganizationLicenseConstants
public const string Refresh = nameof(Refresh); public const string Refresh = nameof(Refresh);
public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod); public const string ExpirationWithoutGracePeriod = nameof(ExpirationWithoutGracePeriod);
public const string Trial = nameof(Trial); public const string Trial = nameof(Trial);
public const string UseAdminSponsoredFamilies = nameof(UseAdminSponsoredFamilies);
} }
public static class UserLicenseConstants public static class UserLicenseConstants

View File

@ -53,6 +53,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)), new(nameof(OrganizationLicenseConstants.Refresh), refresh.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)), new(nameof(OrganizationLicenseConstants.ExpirationWithoutGracePeriod), expirationWithoutGracePeriod.ToString(CultureInfo.InvariantCulture)),
new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()), new(nameof(OrganizationLicenseConstants.Trial), trial.ToString()),
new(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()),
}; };
if (entity.Name is not null) if (entity.Name is not null)
@ -109,6 +110,7 @@ public class OrganizationLicenseClaimsFactory : ILicenseClaimsFactory<Organizati
{ {
claims.Add(new Claim(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString())); claims.Add(new Claim(nameof(OrganizationLicenseConstants.SmServiceAccounts), entity.SmServiceAccounts.ToString()));
} }
claims.Add(new Claim(nameof(OrganizationLicenseConstants.UseAdminSponsoredFamilies), entity.UseAdminSponsoredFamilies.ToString()));
return Task.FromResult(claims); return Task.FromResult(claims);
} }

View File

@ -108,11 +108,11 @@ public static class FeatureFlagKeys
public const string PolicyRequirements = "pm-14439-policy-requirements"; public const string PolicyRequirements = "pm-14439-policy-requirements";
public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility"; public const string SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility";
public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast"; public const string ScimInviteUserOptimization = "pm-16811-optimize-invite-user-flow-to-fail-fast";
public const string EventBasedOrganizationIntegrations = "event-based-organization-integrations";
/* Auth Team */ /* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
public const string DuoRedirect = "duo-redirect";
public const string EmailVerification = "email-verification"; public const string EmailVerification = "email-verification";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
@ -143,7 +143,6 @@ public static class FeatureFlagKeys
public const string TrialPayment = "PM-8163-trial-payment"; public const string TrialPayment = "PM-8163-trial-payment";
public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships"; public const string PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships";
public const string UsePricingService = "use-pricing-service"; public const string UsePricingService = "use-pricing-service";
public const string P15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal";
public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features"; public const string PM12276Breadcrumbing = "pm-12276-breadcrumbing-for-business-features";
public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method"; public const string PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method";
public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements"; public const string PM19147_AutomaticTaxImprovements = "pm-19147-automatic-tax-improvements";
@ -151,6 +150,10 @@ public static class FeatureFlagKeys
public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion"; public const string PM18770_EnableOrganizationBusinessUnitConversion = "pm-18770-enable-organization-business-unit-conversion";
public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically"; public const string PM199566_UpdateMSPToChargeAutomatically = "pm-199566-update-msp-to-charge-automatically";
/* Data Insights and Reporting Team */
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
/* Key Management Team */ /* Key Management Team */
public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair"; public const string ReturnErrorOnExistingKeypair = "return-error-on-existing-keypair";
public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service"; public const string PM4154BulkEncryptionService = "PM-4154-bulk-encryption-service";
@ -187,17 +190,13 @@ public static class FeatureFlagKeys
/* Tools Team */ /* Tools Team */
public const string ItemShare = "item-share"; public const string ItemShare = "item-share";
public const string RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string ExportAttachments = "export-attachments";
/* Vault Team */ /* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form"; public const string PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form";
public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss"; public const string NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss";
public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss"; public const string NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss";
public const string VaultBulkManagementAction = "vault-bulk-management-action";
public const string RestrictProviderAccess = "restrict-provider-access"; public const string RestrictProviderAccess = "restrict-provider-access";
public const string SecurityTasks = "security-tasks"; public const string SecurityTasks = "security-tasks";
public const string CipherKeyEncryption = "cipher-key-encryption"; public const string CipherKeyEncryption = "cipher-key-encryption";
@ -216,9 +215,6 @@ public static class FeatureFlagKeys
public static Dictionary<string, string> GetLocalOverrideFlagValues() public static Dictionary<string, string> GetLocalOverrideFlagValues()
{ {
// place overriding values when needed locally (offline), or return null // place overriding values when needed locally (offline), or return null
return new Dictionary<string, string>() return null;
{
{ DuoRedirect, "true" },
};
} }
} }

View File

@ -23,8 +23,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" /> <PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.61" /> <PackageReference Include="AWSSDK.SimpleEmail" Version="3.7.402.79" />
<PackageReference Include="AWSSDK.SQS" Version="3.7.400.118" /> <PackageReference Include="AWSSDK.SQS" Version="3.7.400.136" />
<PackageReference Include="Azure.Data.Tables" Version="12.9.0" /> <PackageReference Include="Azure.Data.Tables" Version="12.9.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" /> <PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
@ -38,7 +38,7 @@
<PackageReference Include="Handlebars.Net" Version="2.1.6" /> <PackageReference Include="Handlebars.Net" Version="2.1.6" />
<PackageReference Include="MailKit" Version="4.11.0" /> <PackageReference Include="MailKit" Version="4.11.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.46.1" /> <PackageReference Include="Microsoft.Azure.Cosmos" Version="3.49.0" />
<PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" /> <PackageReference Include="Microsoft.Azure.NotificationHubs" Version="4.2.0" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.2" /> <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.7.0" />
@ -52,7 +52,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="5.0.0" /> <PackageReference Include="Sentry.Serilog" Version="5.0.0" />
<PackageReference Include="Duende.IdentityServer" Version="7.1.0" /> <PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />

View File

@ -19,6 +19,34 @@ public class OrganizationLicense : ILicense
{ {
} }
/// <summary>
/// Initializes a new instance of the <see cref="OrganizationLicense"/> class.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ DEPRECATED: This constructor and the entire property-based licensing system is deprecated.
/// Do not add new properties to this constructor or extend its functionality.
/// </para>
/// <para>
/// This implementation has been replaced by a new claims-based licensing system that provides better security
/// and flexibility. The new system uses JWT claims to store and validate license information, making it more
/// secure and easier to extend without requiring changes to the license format.
/// </para>
/// <para>
/// For new license-related features or modifications:
/// 1. Use the claims-based system instead of adding properties here
/// 2. Add new claims to the license token
/// 3. Validate claims in the <see cref="CanUse"/> and <see cref="VerifyData"/> methods
/// </para>
/// <para>
/// This constructor is maintained only for backward compatibility with existing licenses.
/// </para>
/// </remarks>
/// <param name="org">The organization to create the license for.</param>
/// <param name="subscriptionInfo">Information about the organization's subscription.</param>
/// <param name="installationId">The ID of the current installation.</param>
/// <param name="licenseService">The service used to sign the license.</param>
/// <param name="version">Optional version number for the license format.</param>
public OrganizationLicense(Organization org, SubscriptionInfo subscriptionInfo, Guid installationId, public OrganizationLicense(Organization org, SubscriptionInfo subscriptionInfo, Guid installationId,
ILicensingService licenseService, int? version = null) ILicensingService licenseService, int? version = null)
{ {
@ -105,6 +133,7 @@ public class OrganizationLicense : ILicense
Trial = false; Trial = false;
} }
UseAdminSponsoredFamilies = org.UseAdminSponsoredFamilies;
Hash = Convert.ToBase64String(ComputeHash()); Hash = Convert.ToBase64String(ComputeHash());
Signature = Convert.ToBase64String(licenseService.SignLicense(this)); Signature = Convert.ToBase64String(licenseService.SignLicense(this));
} }
@ -153,6 +182,7 @@ public class OrganizationLicense : ILicense
public bool Trial { get; set; } public bool Trial { get; set; }
public LicenseType? LicenseType { get; set; } public LicenseType? LicenseType { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public string Hash { get; set; } public string Hash { get; set; }
public string Signature { get; set; } public string Signature { get; set; }
public string Token { get; set; } public string Token { get; set; }
@ -292,13 +322,35 @@ public class OrganizationLicense : ILicense
} }
/// <summary> /// <summary>
/// Do not extend this method. It is only here for backwards compatibility with old licenses. /// Validates an obsolete license format using property-based validation.
/// Instead, extend the CanUse method using the ClaimsPrincipal.
/// </summary> /// </summary>
/// <param name="globalSettings"></param> /// <remarks>
/// <param name="licensingService"></param> /// <para>
/// <param name="exception"></param> /// ⚠️ DEPRECATED: This method is deprecated and should not be extended or modified.
/// <returns></returns> /// It is maintained only for backward compatibility with old license formats.
/// </para>
/// <para>
/// This method has been replaced by a new claims-based validation system that provides:
/// - Better security through JWT claims
/// - More flexible validation rules
/// - Easier extensibility without changing the license format
/// - Better separation of concerns
/// </para>
/// <para>
/// To add new license validation rules:
/// 1. Add new claims to the license token in the claims-based system
/// 2. Extend the <see cref="CanUse(IGlobalSettings, ILicensingService, ClaimsPrincipal, out string)"/> method
/// 3. Validate the new claims using the ClaimsPrincipal parameter
/// </para>
/// <para>
/// This method will be removed in a future version once all old licenses have been migrated
/// to the new claims-based system.
/// </para>
/// </remarks>
/// <param name="globalSettings">The global settings containing installation information.</param>
/// <param name="licensingService">The service used to verify the license signature.</param>
/// <param name="exception">When the method returns false, contains the error message explaining why the license is invalid.</param>
/// <returns>True if the license is valid, false otherwise.</returns>
private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception) private bool ObsoleteCanUse(IGlobalSettings globalSettings, ILicensingService licensingService, out string exception)
{ {
// Do not extend this method. It is only here for backwards compatibility with old licenses. // Do not extend this method. It is only here for backwards compatibility with old licenses.
@ -392,6 +444,7 @@ public class OrganizationLicense : ILicense
var usePasswordManager = claimsPrincipal.GetValue<bool>(nameof(UsePasswordManager)); var usePasswordManager = claimsPrincipal.GetValue<bool>(nameof(UsePasswordManager));
var smSeats = claimsPrincipal.GetValue<int?>(nameof(SmSeats)); var smSeats = claimsPrincipal.GetValue<int?>(nameof(SmSeats));
var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts)); var smServiceAccounts = claimsPrincipal.GetValue<int?>(nameof(SmServiceAccounts));
var useAdminSponsoredFamilies = claimsPrincipal.GetValue<bool>(nameof(UseAdminSponsoredFamilies));
return issued <= DateTime.UtcNow && return issued <= DateTime.UtcNow &&
expires >= DateTime.UtcNow && expires >= DateTime.UtcNow &&
@ -419,7 +472,9 @@ public class OrganizationLicense : ILicense
useSecretsManager == organization.UseSecretsManager && useSecretsManager == organization.UseSecretsManager &&
usePasswordManager == organization.UsePasswordManager && usePasswordManager == organization.UsePasswordManager &&
smSeats == organization.SmSeats && smSeats == organization.SmSeats &&
smServiceAccounts == organization.SmServiceAccounts; smServiceAccounts == organization.SmServiceAccounts &&
useAdminSponsoredFamilies == organization.UseAdminSponsoredFamilies;
} }
/// <summary> /// <summary>

View File

@ -14,11 +14,17 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte
public class CreateSponsorshipCommand( public class CreateSponsorshipCommand(
ICurrentContext currentContext, ICurrentContext currentContext,
IOrganizationSponsorshipRepository organizationSponsorshipRepository, IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService) : ICreateSponsorshipCommand IUserService userService,
IOrganizationService organizationService) : ICreateSponsorshipCommand
{ {
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrganization, public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
OrganizationUser sponsoringMember, PlanSponsorshipType sponsorshipType, string sponsoredEmail, Organization sponsoringOrganization,
string friendlyName, string notes) OrganizationUser sponsoringMember,
PlanSponsorshipType sponsorshipType,
string sponsoredEmail,
string friendlyName,
bool isAdminInitiated,
string notes)
{ {
var sponsoringUser = await userService.GetUserByIdAsync(sponsoringMember.UserId!.Value); var sponsoringUser = await userService.GetUserByIdAsync(sponsoringMember.UserId!.Value);
@ -48,12 +54,21 @@ public class CreateSponsorshipCommand(
throw new BadRequestException("Can only sponsor one organization per Organization User."); throw new BadRequestException("Can only sponsor one organization per Organization User.");
} }
var sponsorship = new OrganizationSponsorship(); if (isAdminInitiated)
sponsorship.SponsoringOrganizationId = sponsoringOrganization.Id; {
sponsorship.SponsoringOrganizationUserId = sponsoringMember.Id; ValidateAdminInitiatedSponsorship(sponsoringOrganization);
sponsorship.FriendlyName = friendlyName; }
sponsorship.OfferedToEmail = sponsoredEmail;
sponsorship.PlanSponsorshipType = sponsorshipType; var sponsorship = new OrganizationSponsorship
{
SponsoringOrganizationId = sponsoringOrganization.Id,
SponsoringOrganizationUserId = sponsoringMember.Id,
FriendlyName = friendlyName,
OfferedToEmail = sponsoredEmail,
PlanSponsorshipType = sponsorshipType,
IsAdminInitiated = isAdminInitiated,
Notes = notes
};
if (existingOrgSponsorship != null) if (existingOrgSponsorship != null)
{ {
@ -61,35 +76,22 @@ public class CreateSponsorshipCommand(
sponsorship.Id = existingOrgSponsorship.Id; sponsorship.Id = existingOrgSponsorship.Id;
} }
var isAdminInitiated = false; if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
if (currentContext.UserId != sponsoringMember.UserId)
{ {
var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrganization.Id); await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1);
OrganizationUserType[] allowedUserTypes =
[
OrganizationUserType.Admin,
OrganizationUserType.Owner
];
if (!organization.Permissions.ManageUsers && allowedUserTypes.All(x => x != organization.Type))
{
throw new UnauthorizedAccessException("You do not have permissions to send sponsorships on behalf of the organization.");
} }
if (!sponsoringOrganization.UseAdminSponsoredFamilies)
{
throw new BadRequestException("Sponsoring organization cannot sponsor other Family organizations.");
}
isAdminInitiated = true;
}
sponsorship.IsAdminInitiated = isAdminInitiated;
sponsorship.Notes = notes;
try try
{
if (isAdminInitiated)
{
await organizationSponsorshipRepository.CreateAsync(sponsorship);
}
else
{ {
await organizationSponsorshipRepository.UpsertAsync(sponsorship); await organizationSponsorshipRepository.UpsertAsync(sponsorship);
}
return sponsorship; return sponsorship;
} }
catch catch
@ -101,4 +103,24 @@ public class CreateSponsorshipCommand(
throw; throw;
} }
} }
private void ValidateAdminInitiatedSponsorship(Organization sponsoringOrganization)
{
var organization = currentContext.Organizations.First(x => x.Id == sponsoringOrganization.Id);
OrganizationUserType[] allowedUserTypes =
[
OrganizationUserType.Admin,
OrganizationUserType.Owner
];
if (!organization.Permissions.ManageUsers && allowedUserTypes.All(x => x != organization.Type))
{
throw new UnauthorizedAccessException("You do not have permissions to send sponsorships on behalf of the organization");
}
if (!sponsoringOrganization.UseAdminSponsoredFamilies)
{
throw new BadRequestException("Sponsoring organization cannot send admin-initiated sponsorship invitations");
}
}
} }

View File

@ -6,6 +6,12 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte
public interface ICreateSponsorshipCommand public interface ICreateSponsorshipCommand
{ {
Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser, Task<OrganizationSponsorship> CreateSponsorshipAsync(
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName, string notes); Organization sponsoringOrg,
OrganizationUser sponsoringOrgUser,
PlanSponsorshipType sponsorshipType,
string sponsoredEmail,
string friendlyName,
bool isAdminInitiated,
string notes);
} }

View File

@ -12,7 +12,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -53,6 +53,7 @@ public class GlobalSettings : IGlobalSettings
public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings(); public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings();
public virtual SqlSettings MySql { get; set; } = new SqlSettings(); public virtual SqlSettings MySql { get; set; } = new SqlSettings();
public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" }; public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" };
public virtual SlackSettings Slack { get; set; } = new SlackSettings();
public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings();
public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings();
public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings();
@ -271,10 +272,17 @@ public class GlobalSettings : IGlobalSettings
} }
} }
public class SlackSettings
{
public virtual string ApiBaseUrl { get; set; } = "https://slack.com/api";
public virtual string ClientId { get; set; }
public virtual string ClientSecret { get; set; }
public virtual string Scopes { get; set; }
}
public class EventLoggingSettings public class EventLoggingSettings
{ {
public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
public virtual string WebhookUrl { get; set; }
public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings();
public class AzureServiceBusSettings public class AzureServiceBusSettings
@ -283,6 +291,7 @@ public class GlobalSettings : IGlobalSettings
private string _topicName; private string _topicName;
public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription"; public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription";
public virtual string SlackSubscriptionName { get; set; } = "events-slack-subscription";
public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription"; public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription";
public string ConnectionString public string ConnectionString
@ -307,6 +316,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue"; public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue";
public virtual string WebhookQueueName { get; set; } = "events-webhook-queue"; public virtual string WebhookQueueName { get; set; } = "events-webhook-queue";
public virtual string SlackQueueName { get; set; } = "events-slack-queue";
public string HostName public string HostName
{ {

View File

@ -21,7 +21,7 @@ using Bit.Core.Context;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Identity; using Bit.Core.Identity;
using Bit.Core.Settings; using Bit.Core.Settings;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using MimeKit; using MimeKit;

View File

@ -1,12 +1,14 @@
using System.Globalization; using System.Globalization;
using Bit.Core;
using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.Implementations;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Duende.IdentityModel; using IdentityModel;
namespace Bit.Events; namespace Bit.Events;
@ -61,33 +63,45 @@ public class Startup
{ {
services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>(); services.AddSingleton<IApplicationCacheService, InMemoryApplicationCacheService>();
} }
services.AddScoped<IEventService, EventService>();
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
{ {
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName)) CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{ {
services.AddSingleton<IEventWriteService, AzureServiceBusEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
} }
else else
{ {
services.AddSingleton<IEventWriteService, AzureQueueEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
} }
} }
else else
{ {
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) && if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
{ {
services.AddSingleton<IEventWriteService, RabbitMqEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
} }
else else
{ {
services.AddSingleton<IEventWriteService, RepositoryEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
} }
} }
services.AddScoped<IEventWriteService>(sp =>
{
var featureService = sp.GetRequiredService<IFeatureService>();
var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)
? "broadcast" : "storage";
return sp.GetRequiredKeyedService<IEventWriteService>(key);
});
services.AddScoped<IEventService, EventService>();
services.AddOptionality(); services.AddOptionality();
@ -117,11 +131,27 @@ public class Startup
globalSettings, globalSettings,
globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName)); globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName));
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.WebhookUrl)) if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{ {
services.AddSingleton<WebhookEventHandler>(); services.AddHttpClient(SlackService.HttpClientName);
services.AddHttpClient(WebhookEventHandler.HttpClientName); services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
services.AddSingleton<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<SlackEventHandler>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.SlackQueueName));
services.AddHttpClient(WebhookEventHandler.HttpClientName);
services.AddSingleton<WebhookEventHandler>();
services.AddSingleton<IHostedService>(provider => services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService( new RabbitMqEventListenerService(
provider.GetRequiredService<WebhookEventHandler>(), provider.GetRequiredService<WebhookEventHandler>(),
@ -130,7 +160,6 @@ public class Startup
globalSettings.EventLogging.RabbitMq.WebhookQueueName)); globalSettings.EventLogging.RabbitMq.WebhookQueueName));
} }
} }
}
public void Configure( public void Configure(
IApplicationBuilder app, IApplicationBuilder app,

View File

@ -1,4 +1,5 @@
using System.Globalization; using System.Globalization;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
@ -29,6 +30,12 @@ public class Startup
// Settings // Settings
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment); var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
// Data Protection
services.AddCustomDataProtectionServices(Environment, globalSettings);
// Repositories
services.AddDatabaseRepositories(globalSettings);
// Hosted Services // Hosted Services
// Optional Azure Service Bus Listeners // Optional Azure Service Bus Listeners
@ -45,8 +52,25 @@ public class Startup
globalSettings, globalSettings,
globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName)); globalSettings.EventLogging.AzureServiceBus.EventRepositorySubscriptionName));
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.WebhookUrl)) if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{ {
services.AddHttpClient(SlackService.HttpClientName);
services.AddSingleton<ISlackService, SlackService>();
}
else
{
services.AddSingleton<ISlackService, NoopSlackService>();
}
services.AddSingleton<SlackEventHandler>();
services.AddSingleton<IHostedService>(provider =>
new AzureServiceBusEventListenerService(
provider.GetRequiredService<SlackEventHandler>(),
provider.GetRequiredService<ILogger<AzureServiceBusEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.AzureServiceBus.SlackSubscriptionName));
services.AddSingleton<WebhookEventHandler>(); services.AddSingleton<WebhookEventHandler>();
services.AddHttpClient(WebhookEventHandler.HttpClientName); services.AddHttpClient(WebhookEventHandler.HttpClientName);
@ -57,7 +81,6 @@ public class Startup
globalSettings, globalSettings,
globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName)); globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName));
} }
}
services.AddHostedService<AzureQueueHostedService>(); services.AddHostedService<AzureQueueHostedService>();
} }

View File

@ -5,9 +5,9 @@ using Bit.Core.Entities;
using Bit.Core.Models.Api; using Bit.Core.Models.Api;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Identity.Models; using Bit.Identity.Models;
using Duende.IdentityModel;
using Duende.IdentityServer; using Duende.IdentityServer;
using Duende.IdentityServer.Services; using Duende.IdentityServer.Services;
using IdentityModel;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;

View File

@ -1,7 +1,7 @@
using Bit.Core.Identity; using Bit.Core.Identity;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Duende.IdentityModel;
using Duende.IdentityServer.Models; using Duende.IdentityServer.Models;
using IdentityModel;
namespace Bit.Identity.IdentityServer; namespace Bit.Identity.IdentityServer;

View File

@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
@ -39,6 +40,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected ISsoConfigRepository SsoConfigRepository { get; } protected ISsoConfigRepository SsoConfigRepository { get; }
protected IUserService _userService { get; } protected IUserService _userService { get; }
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; } protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
protected IPolicyRequirementQuery PolicyRequirementQuery { get; }
public BaseRequestValidator( public BaseRequestValidator(
UserManager<User> userManager, UserManager<User> userManager,
@ -55,7 +57,8 @@ public abstract class BaseRequestValidator<T> where T : class
IPolicyService policyService, IPolicyService policyService,
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery)
{ {
_userManager = userManager; _userManager = userManager;
_userService = userService; _userService = userService;
@ -72,6 +75,7 @@ public abstract class BaseRequestValidator<T> where T : class
FeatureService = featureService; FeatureService = featureService;
SsoConfigRepository = ssoConfigRepository; SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder; UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
PolicyRequirementQuery = policyRequirementQuery;
} }
protected async Task ValidateAsync(T context, ValidatedTokenRequest request, protected async Task ValidateAsync(T context, ValidatedTokenRequest request,
@ -348,9 +352,12 @@ public abstract class BaseRequestValidator<T> where T : class
} }
// Check if user belongs to any organization with an active SSO policy // Check if user belongs to any organization with an active SSO policy
var anySsoPoliciesApplicableToUser = await PolicyService.AnyPoliciesApplicableToUserAsync( var ssoRequired = FeatureService.IsEnabled(FeatureFlagKeys.PolicyRequirements)
? (await PolicyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(user.Id))
.SsoRequired
: await PolicyService.AnyPoliciesApplicableToUserAsync(
user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed); user.Id, PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
if (anySsoPoliciesApplicableToUser) if (ssoRequired)
{ {
return true; return true;
} }

View File

@ -1,6 +1,7 @@
using System.Diagnostics; using System.Diagnostics;
using System.Security.Claims; using System.Security.Claims;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Models.Api.Response; using Bit.Core.Auth.Models.Api.Response;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
@ -11,10 +12,10 @@ using Bit.Core.Platform.Installations;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Duende.IdentityModel;
using Duende.IdentityServer.Extensions; using Duende.IdentityServer.Extensions;
using Duende.IdentityServer.Validation; using Duende.IdentityServer.Validation;
using HandlebarsDotNet; using HandlebarsDotNet;
using IdentityModel;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
#nullable enable #nullable enable
@ -43,8 +44,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IUpdateInstallationCommand updateInstallationCommand IUpdateInstallationCommand updateInstallationCommand,
) IPolicyRequirementQuery policyRequirementQuery)
: base( : base(
userManager, userManager,
userService, userService,
@ -60,7 +61,8 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
policyService, policyService,
featureService, featureService,
ssoConfigRepository, ssoConfigRepository,
userDecryptionOptionsBuilder) userDecryptionOptionsBuilder,
policyRequirementQuery)
{ {
_userManager = userManager; _userManager = userManager;
_updateInstallationCommand = updateInstallationCommand; _updateInstallationCommand = updateInstallationCommand;

View File

@ -1,5 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
@ -40,7 +41,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
IPolicyService policyService, IPolicyService policyService,
IFeatureService featureService, IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository, ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery)
: base( : base(
userManager, userManager,
userService, userService,
@ -56,7 +58,8 @@ public class ResourceOwnerPasswordValidator : BaseRequestValidator<ResourceOwner
policyService, policyService,
featureService, featureService,
ssoConfigRepository, ssoConfigRepository,
userDecryptionOptionsBuilder) userDecryptionOptionsBuilder,
policyRequirementQuery)
{ {
_userManager = userManager; _userManager = userManager;
_currentContext = currentContext; _currentContext = currentContext;

View File

@ -1,6 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json; using System.Text.Json;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
@ -44,7 +45,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector, IDataProtectorTokenFactory<WebAuthnLoginAssertionOptionsTokenable> assertionOptionsDataProtector,
IFeatureService featureService, IFeatureService featureService,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder, IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand) IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IPolicyRequirementQuery policyRequirementQuery)
: base( : base(
userManager, userManager,
userService, userService,
@ -60,7 +62,8 @@ public class WebAuthnGrantValidator : BaseRequestValidator<ExtensionGrantValidat
policyService, policyService,
featureService, featureService,
ssoConfigRepository, ssoConfigRepository,
userDecryptionOptionsBuilder) userDecryptionOptionsBuilder,
policyRequirementQuery)
{ {
_assertionOptionsDataProtector = assertionOptionsDataProtector; _assertionOptionsDataProtector = assertionOptionsDataProtector;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand; _assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;

View File

@ -150,6 +150,8 @@ public static class DapperHelpers
os => os.LastSyncDate, os => os.LastSyncDate,
os => os.ValidUntil, os => os.ValidUntil,
os => os.ToDelete, os => os.ToDelete,
os => os.IsAdminInitiated,
os => os.Notes,
] ]
); );

View File

@ -41,6 +41,8 @@ public static class DapperServiceCollectionExtensions
services.AddSingleton<IMaintenanceRepository, MaintenanceRepository>(); services.AddSingleton<IMaintenanceRepository, MaintenanceRepository>();
services.AddSingleton<IOrganizationApiKeyRepository, OrganizationApiKeyRepository>(); services.AddSingleton<IOrganizationApiKeyRepository, OrganizationApiKeyRepository>();
services.AddSingleton<IOrganizationConnectionRepository, OrganizationConnectionRepository>(); services.AddSingleton<IOrganizationConnectionRepository, OrganizationConnectionRepository>();
services.AddSingleton<IOrganizationIntegrationConfigurationRepository, OrganizationIntegrationConfigurationRepository>();
services.AddSingleton<IOrganizationIntegrationRepository, OrganizationIntegrationRepository>();
services.AddSingleton<IOrganizationRepository, OrganizationRepository>(); services.AddSingleton<IOrganizationRepository, OrganizationRepository>();
services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>(); services.AddSingleton<IOrganizationSponsorshipRepository, OrganizationSponsorshipRepository>();
services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>(); services.AddSingleton<IOrganizationUserRepository, OrganizationUserRepository>();

View File

@ -10,7 +10,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" /> <PackageReference Include="Dapper" Version="2.1.66" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -12,6 +12,12 @@ public class OrganizationIntegrationConfigurationEntityTypeConfiguration : IEnti
.Property(oic => oic.Id) .Property(oic => oic.Id)
.ValueGeneratedNever(); .ValueGeneratedNever();
builder
.HasOne(oic => oic.OrganizationIntegration)
.WithMany()
.HasForeignKey(oic => oic.OrganizationIntegrationId)
.OnDelete(DeleteBehavior.Cascade);
builder.ToTable(nameof(OrganizationIntegrationConfiguration)); builder.ToTable(nameof(OrganizationIntegrationConfiguration));
} }
} }

View File

@ -14,9 +14,23 @@ public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery
public IQueryable<OrganizationUser> Run(DatabaseContext dbContext) public IQueryable<OrganizationUser> Run(DatabaseContext dbContext)
{ {
var query = from ou in dbContext.OrganizationUsers var orgUsersQuery = from ou in dbContext.OrganizationUsers
where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited where ou.OrganizationId == _organizationId && ou.Status >= OrganizationUserStatusType.Invited
select ou; select new OrganizationUser { Id = ou.Id, OrganizationId = ou.OrganizationId, Status = ou.Status };
return query;
// As of https://bitwarden.atlassian.net/browse/PM-17772, a seat is also occupied by a Families for Enterprise sponsorship sent by an
// organization admin, even if the user sent the invitation doesn't have a corresponding OrganizationUser in the Enterprise organization.
var sponsorshipsQuery = from os in dbContext.OrganizationSponsorships
where os.SponsoringOrganizationId == _organizationId &&
os.IsAdminInitiated &&
!os.ToDelete
select new OrganizationUser
{
Id = os.Id,
OrganizationId = _organizationId,
Status = OrganizationUserStatusType.Invited
};
return orgUsersQuery.Concat(sponsorshipsQuery);
} }
} }

View File

@ -8,12 +8,12 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" /> <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="linq2db" Version="5.4.1" /> <PackageReference Include="linq2db" Version="5.4.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="[8.0.8]" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="[8.0.8]" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="[8.0.8]" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="[8.0.4]" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="[8.0.2]" />
<PackageReference Include="linq2db.EntityFrameworkCore" Version="8.1.0" /> <PackageReference Include="linq2db.EntityFrameworkCore" Version="[8.1.0]" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -34,6 +34,12 @@ public class NotificationEntityTypeConfiguration : IEntityTypeConfiguration<Noti
.HasIndex(n => n.TaskId) .HasIndex(n => n.TaskId)
.IsClustered(false); .IsClustered(false);
builder
.HasOne(n => n.Task)
.WithMany()
.HasForeignKey(n => n.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.ToTable(nameof(Notification)); builder.ToTable(nameof(Notification));
} }
} }

View File

@ -24,6 +24,18 @@ public class SecurityTaskEntityTypeConfiguration : IEntityTypeConfiguration<Secu
.HasIndex(s => s.CipherId) .HasIndex(s => s.CipherId)
.IsClustered(false); .IsClustered(false);
builder
.HasOne(p => p.Organization)
.WithMany()
.HasForeignKey(p => p.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder
.HasOne(p => p.Cipher)
.WithMany()
.HasForeignKey(p => p.CipherId)
.OnDelete(DeleteBehavior.Cascade);
builder builder
.ToTable(nameof(SecurityTask)); .ToTable(nameof(SecurityTask));
} }

View File

@ -3,7 +3,7 @@ using Bit.Core.IdentityServer;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Duende.IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.IdentityModel.Logging; using Microsoft.IdentityModel.Logging;

View File

@ -1,4 +1,4 @@
using Duende.IdentityModel; using IdentityModel;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace Bit.Notifications; namespace Bit.Notifications;

View File

@ -4,6 +4,7 @@ using System.Security.Claims;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using AspNetCoreRateLimit; using AspNetCoreRateLimit;
using Azure.Storage.Queues; using Azure.Storage.Queues;
using Bit.Core;
using Bit.Core.AdminConsole.Models.Business.Tokenables; using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies; using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
@ -50,7 +51,7 @@ using Bit.Core.Vault.Services;
using Bit.Infrastructure.Dapper; using Bit.Infrastructure.Dapper;
using Bit.Infrastructure.EntityFramework; using Bit.Infrastructure.EntityFramework;
using DnsClient; using DnsClient;
using Duende.IdentityModel; using IdentityModel;
using LaunchDarkly.Sdk.Server; using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Interfaces; using LaunchDarkly.Sdk.Server.Interfaces;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
@ -332,34 +333,46 @@ public static class ServiceCollectionExtensions
if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString)) if (!globalSettings.SelfHosted && CoreHelpers.SettingHasValue(globalSettings.Events.ConnectionString))
{ {
services.AddKeyedSingleton<IEventWriteService, AzureQueueEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) && if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.ConnectionString) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName)) CoreHelpers.SettingHasValue(globalSettings.EventLogging.AzureServiceBus.TopicName))
{ {
services.AddSingleton<IEventWriteService, AzureServiceBusEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, AzureServiceBusEventWriteService>("broadcast");
} }
else else
{ {
services.AddSingleton<IEventWriteService, AzureQueueEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
} }
} }
else if (globalSettings.SelfHosted) else if (globalSettings.SelfHosted)
{ {
services.AddKeyedSingleton<IEventWriteService, RepositoryEventWriteService>("storage");
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) && if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.HostName) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Username) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) && CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.Password) &&
CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName)) CoreHelpers.SettingHasValue(globalSettings.EventLogging.RabbitMq.ExchangeName))
{ {
services.AddSingleton<IEventWriteService, RabbitMqEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, RabbitMqEventWriteService>("broadcast");
} }
else else
{ {
services.AddSingleton<IEventWriteService, RepositoryEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
} }
} }
else else
{ {
services.AddSingleton<IEventWriteService, NoopEventWriteService>(); services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("storage");
services.AddKeyedSingleton<IEventWriteService, NoopEventWriteService>("broadcast");
} }
services.AddScoped<IEventWriteService>(sp =>
{
var featureService = sp.GetRequiredService<IFeatureService>();
var key = featureService.IsEnabled(FeatureFlagKeys.EventBasedOrganizationIntegrations)
? "broadcast" : "storage";
return sp.GetRequiredKeyedService<IEventWriteService>(key);
});
if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString)) if (CoreHelpers.SettingHasValue(globalSettings.Attachment.ConnectionString))
{ {

View File

@ -14,7 +14,7 @@ CREATE TABLE [dbo].[Notification]
CONSTRAINT [PK_Notification] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [PK_Notification] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_Notification_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_Notification_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]),
CONSTRAINT [FK_Notification_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]), CONSTRAINT [FK_Notification_User] FOREIGN KEY ([UserId]) REFERENCES [dbo].[User] ([Id]),
CONSTRAINT [FK_Notification_SecurityTask] FOREIGN KEY ([TaskId]) REFERENCES [dbo].[SecurityTask] ([Id]) CONSTRAINT [FK_Notification_SecurityTask] FOREIGN KEY ([TaskId]) REFERENCES [dbo].[SecurityTask] ([Id]) ON DELETE CASCADE
); );

View File

@ -5,10 +5,19 @@ BEGIN
SET NOCOUNT ON SET NOCOUNT ON
SELECT SELECT
COUNT(1) (
FROM -- Count organization users
[dbo].[OrganizationUserView] SELECT COUNT(1)
WHERE FROM [dbo].[OrganizationUserView]
OrganizationId = @OrganizationId WHERE OrganizationId = @OrganizationId
AND Status >= 0 --Invited AND Status >= 0 --Invited
) +
(
-- Count admin-initiated sponsorships towards the seat count
-- Introduced in https://bitwarden.atlassian.net/browse/PM-17772
SELECT COUNT(1)
FROM [dbo].[OrganizationSponsorship]
WHERE SponsoringOrganizationId = @OrganizationId
AND IsAdminInitiated = 1
)
END END

View File

@ -6,7 +6,7 @@ CREATE TABLE [dbo].[PasswordHealthReportApplication]
CreationDate DATETIME2(7) NOT NULL, CreationDate DATETIME2(7) NOT NULL,
RevisionDate DATETIME2(7) NOT NULL, RevisionDate DATETIME2(7) NOT NULL,
CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
); );
GO GO

View File

@ -0,0 +1,201 @@
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(OrganizationIntegrationController))]
[SutProviderCustomize]
public class OrganizationIntegrationControllerTests
{
private OrganizationIntegrationRequestModel _webhookRequestModel = new OrganizationIntegrationRequestModel()
{
Configuration = null,
Type = IntegrationType.Webhook
};
[Theory, BitAutoData]
public async Task CreateAsync_Webhook_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(callInfo => callInfo.Arg<OrganizationIntegration>());
var response = await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<OrganizationIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(organizationId, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task DeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.DeleteAsync(organizationIntegration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty));
}
[Theory, BitAutoData]
public async Task UpdateAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
var response = await sutProvider.Sut.UpdateAsync(organizationId, organizationIntegration.Id, _webhookRequestModel);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.ReplaceAsync(organizationIntegration);
Assert.IsType<OrganizationIntegrationResponseModel>(response);
Assert.Equal(IntegrationType.Webhook, response.Type);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = Guid.NewGuid();
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
[Theory, BitAutoData]
public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(organizationId, Guid.Empty, _webhookRequestModel));
}
}

View File

@ -0,0 +1,621 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Controllers;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using NSubstitute.ReturnsExtensions;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(OrganizationIntegrationConfigurationController))]
[SutProviderCustomize]
public class OrganizationIntegrationsConfigurationControllerTests
{
[Theory, BitAutoData]
public async Task DeleteAsync_AllParamsProvided_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, organizationIntegrationConfiguration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.GetByIdAsync(organizationIntegration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.GetByIdAsync(organizationIntegrationConfiguration.Id);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.DeleteAsync(organizationIntegrationConfiguration);
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
organizationIntegration.OrganizationId = organizationId;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_IntegrationConfigDoesNotBelongToIntegration_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = Guid.Empty;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, organizationIntegration.Id, Guid.Empty));
}
[Theory, BitAutoData]
public async Task DeleteAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.DeleteAsync(organizationId, Guid.Empty, Guid.Empty));
}
[Theory, BitAutoData]
public async Task PostAsync_AllParamsProvided_Slack_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Slack;
var slackConfig = new SlackIntegrationConfiguration(channelId: "C123456");
model.Configuration = JsonSerializer.Serialize(slackConfig);
model.Template = "Template String";
var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration);
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
}
[Theory, BitAutoData]
public async Task PostAsync_AllParamsProvided_Webhook_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost");
model.Configuration = JsonSerializer.Serialize(webhookConfig);
model.Template = "Template String";
var expected = new OrganizationIntegrationConfigurationResponseModel(organizationIntegrationConfiguration);
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, organizationIntegration.Id, model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
}
[Theory, BitAutoData]
public async Task PostAsync_IntegrationTypeCloudBillingSync_ThrowsBadRequestException(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.CloudBillingSync;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(
organizationId,
organizationIntegration.Id,
model));
}
[Theory, BitAutoData]
public async Task PostAsync_IntegrationTypeScim_ThrowsBadRequestException(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Scim;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(
organizationId,
organizationIntegration.Id,
model));
}
[Theory, BitAutoData]
public async Task PostAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(
organizationId,
Guid.Empty,
new OrganizationIntegrationConfigurationRequestModel()));
}
[Theory, BitAutoData]
public async Task PostAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(
organizationId,
organizationIntegration.Id,
new OrganizationIntegrationConfigurationRequestModel()));
}
[Theory, BitAutoData]
public async Task PostAsync_InvalidConfiguration_ThrowsBadRequestException(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
model.Configuration = null;
model.Template = "Template String";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(
organizationId,
organizationIntegration.Id,
model));
}
[Theory, BitAutoData]
public async Task PostAsync_InvalidTemplate_ThrowsBadRequestException(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost");
model.Configuration = JsonSerializer.Serialize(webhookConfig);
model.Template = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegrationConfiguration>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(
organizationId,
organizationIntegration.Id,
model));
}
[Theory, BitAutoData]
public async Task PostAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<OrganizationIntegrationConfigurationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(organizationId, Guid.Empty, new OrganizationIntegrationConfigurationRequestModel()));
}
[Theory, BitAutoData]
public async Task UpdateAsync_AllParamsProvided_Slack_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
organizationIntegration.Type = IntegrationType.Slack;
var slackConfig = new SlackIntegrationConfiguration(channelId: "C123456");
model.Configuration = JsonSerializer.Serialize(slackConfig);
model.Template = "Template String";
var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration));
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
}
[Theory, BitAutoData]
public async Task UpdateAsync_AllParamsProvided_Webhook_Succeeds(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
organizationIntegration.Type = IntegrationType.Webhook;
var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost");
model.Configuration = JsonSerializer.Serialize(webhookConfig);
model.Template = "Template String";
var expected = new OrganizationIntegrationConfigurationResponseModel(model.ToOrganizationIntegrationConfiguration(organizationIntegrationConfiguration));
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
var requestAction = await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
model);
await sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>().Received(1)
.ReplaceAsync(Arg.Any<OrganizationIntegrationConfiguration>());
Assert.IsType<OrganizationIntegrationConfigurationResponseModel>(requestAction);
Assert.Equal(expected.Id, requestAction.Id);
Assert.Equal(expected.Configuration, requestAction.Configuration);
Assert.Equal(expected.EventType, requestAction.EventType);
Assert.Equal(expected.Template, requestAction.Template);
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationConfigurationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegration.Type = IntegrationType.Webhook;
var webhookConfig = new WebhookIntegrationConfiguration(url: "https://localhost");
model.Configuration = JsonSerializer.Serialize(webhookConfig);
model.Template = "Template String";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
Guid.Empty,
model));
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotExist_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.ReturnsNull();
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(
organizationId,
Guid.Empty,
Guid.Empty,
new OrganizationIntegrationConfigurationRequestModel()));
}
[Theory, BitAutoData]
public async Task UpdateAsync_IntegrationDoesNotBelongToOrganization_ThrowsNotFound(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
Guid.Empty,
new OrganizationIntegrationConfigurationRequestModel()));
}
[Theory, BitAutoData]
public async Task UpdateAsync_InvalidConfiguration_ThrowsBadRequestException(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
organizationIntegration.Type = IntegrationType.Slack;
model.Configuration = null;
model.Template = "Template String";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
model));
}
[Theory, BitAutoData]
public async Task UpdateAsync_InvalidTemplate_ThrowsBadRequestException(
SutProvider<OrganizationIntegrationConfigurationController> sutProvider,
Guid organizationId,
OrganizationIntegration organizationIntegration,
OrganizationIntegrationConfiguration organizationIntegrationConfiguration,
OrganizationIntegrationConfigurationRequestModel model)
{
organizationIntegration.OrganizationId = organizationId;
organizationIntegrationConfiguration.OrganizationIntegrationId = organizationIntegration.Id;
organizationIntegration.Type = IntegrationType.Slack;
var slackConfig = new SlackIntegrationConfiguration(channelId: "C123456");
model.Configuration = JsonSerializer.Serialize(slackConfig);
model.Template = null;
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegration);
sutProvider.GetDependency<IOrganizationIntegrationConfigurationRepository>()
.GetByIdAsync(Arg.Any<Guid>())
.Returns(organizationIntegrationConfiguration);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.UpdateAsync(
organizationId,
organizationIntegration.Id,
organizationIntegrationConfiguration.Id,
model));
}
[Theory, BitAutoData]
public async Task UpdateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<OrganizationIntegrationConfigurationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.UpdateAsync(
organizationId,
Guid.Empty,
Guid.Empty,
new OrganizationIntegrationConfigurationRequestModel()));
}
}

View File

@ -0,0 +1,130 @@
using Bit.Api.AdminConsole.Controllers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Controllers;
[ControllerCustomize(typeof(SlackIntegrationController))]
[SutProviderCustomize]
public class SlackIntegrationControllerTests
{
[Theory, BitAutoData]
public async Task CreateAsync_AllParamsProvided_Succeeds(SutProvider<SlackIntegrationController> sutProvider, Guid organizationId)
{
var token = "xoxb-test-token";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(token);
sutProvider.GetDependency<IOrganizationIntegrationRepository>()
.CreateAsync(Arg.Any<OrganizationIntegration>())
.Returns(callInfo => callInfo.Arg<OrganizationIntegration>());
var requestAction = await sutProvider.Sut.CreateAsync(organizationId, "A_test_code");
await sutProvider.GetDependency<IOrganizationIntegrationRepository>().Received(1)
.CreateAsync(Arg.Any<OrganizationIntegration>());
Assert.IsType<CreatedResult>(requestAction);
}
[Theory, BitAutoData]
public async Task CreateAsync_CodeIsEmpty_ThrowsBadRequest(SutProvider<SlackIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(organizationId, string.Empty));
}
[Theory, BitAutoData]
public async Task CreateAsync_SlackServiceReturnsEmpty_ThrowsBadRequest(SutProvider<SlackIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(string.Empty);
await Assert.ThrowsAsync<BadRequestException>(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code"));
}
[Theory, BitAutoData]
public async Task CreateAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<SlackIntegrationController> sutProvider, Guid organizationId)
{
var token = "xoxb-test-token";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
sutProvider.GetDependency<ISlackService>()
.ObtainTokenViaOAuth(Arg.Any<string>(), Arg.Any<string>())
.Returns(token);
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.CreateAsync(organizationId, "A_test_code"));
}
[Theory, BitAutoData]
public async Task RedirectAsync_Success(SutProvider<SlackIntegrationController> sutProvider, Guid organizationId)
{
var expectedUrl = $"https://localhost/{organizationId}";
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(expectedUrl);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.HttpContext.Request.Scheme
.Returns("https");
var requestAction = await sutProvider.Sut.RedirectAsync(organizationId);
var redirectResult = Assert.IsType<RedirectResult>(requestAction);
Assert.Equal(expectedUrl, redirectResult.Url);
}
[Theory, BitAutoData]
public async Task RedirectAsync_SlackServiceReturnsEmpty_ThrowsNotFound(SutProvider<SlackIntegrationController> sutProvider, Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(string.Empty);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(true);
sutProvider.GetDependency<ICurrentContext>()
.HttpContext.Request.Scheme
.Returns("https");
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
[Theory, BitAutoData]
public async Task RedirectAsync_UserIsNotOrganizationAdmin_ThrowsNotFound(SutProvider<SlackIntegrationController> sutProvider,
Guid organizationId)
{
sutProvider.Sut.Url = Substitute.For<IUrlHelper>();
sutProvider.GetDependency<ISlackService>().GetRedirectUrl(Arg.Any<string>()).Returns(string.Empty);
sutProvider.GetDependency<ICurrentContext>()
.OrganizationOwner(organizationId)
.Returns(false);
sutProvider.GetDependency<ICurrentContext>()
.HttpContext.Request.Scheme
.Returns("https");
await Assert.ThrowsAsync<NotFoundException>(async () => await sutProvider.Sut.RedirectAsync(organizationId));
}
}

View File

@ -0,0 +1,120 @@
using System.Text.Json;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Integrations;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationConfigurationRequestModelTests
{
[Fact]
public void IsValidForType_CloudBillingSyncIntegration_ReturnsFalse()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = "{}",
Template = "template"
};
Assert.False(model.IsValidForType(IntegrationType.CloudBillingSync));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValidForType_EmptyConfiguration_ReturnsFalse(string? config)
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = config,
Template = "template"
};
var result = model.IsValidForType(IntegrationType.Slack);
Assert.False(result);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValidForType_EmptyTemplate_ReturnsFalse(string? template)
{
var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com"));
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = config,
Template = template
};
Assert.False(model.IsValidForType(IntegrationType.Webhook));
}
[Fact]
public void IsValidForType_InvalidJsonConfiguration_ReturnsFalse()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = "{not valid json}",
Template = "template"
};
Assert.False(model.IsValidForType(IntegrationType.Webhook));
}
[Fact]
public void IsValidForType_ScimIntegration_ReturnsFalse()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = "{}",
Template = "template"
};
Assert.False(model.IsValidForType(IntegrationType.Scim));
}
[Fact]
public void IsValidForType_ValidSlackConfiguration_ReturnsTrue()
{
var config = JsonSerializer.Serialize(new SlackIntegrationConfiguration("C12345"));
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = config,
Template = "template"
};
Assert.True(model.IsValidForType(IntegrationType.Slack));
}
[Fact]
public void IsValidForType_ValidWebhookConfiguration_ReturnsTrue()
{
var config = JsonSerializer.Serialize(new WebhookIntegrationConfiguration("https://example.com"));
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = config,
Template = "template"
};
Assert.True(model.IsValidForType(IntegrationType.Webhook));
}
[Fact]
public void IsValidForType_UnknownIntegrationType_ReturnsFalse()
{
var model = new OrganizationIntegrationConfigurationRequestModel
{
Configuration = "{}",
Template = "template"
};
var unknownType = (IntegrationType)999;
Assert.False(model.IsValidForType(unknownType));
}
}

View File

@ -0,0 +1,103 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Core.Enums;
using Xunit;
namespace Bit.Api.Test.AdminConsole.Models.Request.Organizations;
public class OrganizationIntegrationRequestModelTests
{
[Fact]
public void Validate_CloudBillingSync_ReturnsNotYetSupportedError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.CloudBillingSync,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("not yet supported", results[0].ErrorMessage);
}
[Fact]
public void Validate_Scim_ReturnsNotYetSupportedError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Scim,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("not yet supported", results[0].ErrorMessage);
}
[Fact]
public void Validate_Slack_ReturnsCannotBeCreatedDirectlyError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Slack,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("cannot be created directly", results[0].ErrorMessage);
}
[Fact]
public void Validate_Webhook_WithNullConfiguration_ReturnsNoErrors()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Webhook,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Empty(results);
}
[Fact]
public void Validate_Webhook_WithConfiguration_ReturnsConfigurationError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = IntegrationType.Webhook,
Configuration = "something"
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Configuration), results[0].MemberNames);
Assert.Contains("must not include configuration", results[0].ErrorMessage);
}
[Fact]
public void Validate_UnknownIntegrationType_ReturnsUnrecognizedError()
{
var model = new OrganizationIntegrationRequestModel
{
Type = (IntegrationType)999,
Configuration = null
};
var results = model.Validate(new ValidationContext(model)).ToList();
Assert.Single(results);
Assert.Contains(nameof(model.Type), results[0].MemberNames);
Assert.Contains("not recognized", results[0].ErrorMessage);
}
}

View File

@ -63,7 +63,9 @@ public class DevicesControllerTest
UserId = userId, UserId = userId,
Name = "chrome", Name = "chrome",
Type = DeviceType.ChromeBrowser, Type = DeviceType.ChromeBrowser,
Identifier = Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString() Identifier = Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString(),
EncryptedPublicKey = "PublicKey",
EncryptedUserKey = "UserKey",
}, },
Guid.Parse("E09D6943-D574-49E5-AC85-C3F12B4E019E"), Guid.Parse("E09D6943-D574-49E5-AC85-C3F12B4E019E"),
authDateTimeResponse) authDateTimeResponse)
@ -78,6 +80,13 @@ public class DevicesControllerTest
// Assert // Assert
Assert.NotNull(result); Assert.NotNull(result);
Assert.IsType<ListResponseModel<DeviceAuthRequestResponseModel>>(result); Assert.IsType<ListResponseModel<DeviceAuthRequestResponseModel>>(result);
var resultDevice = result.Data.First();
Assert.Equal("chrome", resultDevice.Name);
Assert.Equal(DeviceType.ChromeBrowser, resultDevice.Type);
Assert.Equal(Guid.Parse("B3136B10-7818-444F-B05B-4D7A9B8C48BF"), resultDevice.Id);
Assert.Equal(Guid.Parse("811E9254-F77C-48C8-AF0A-A181943F5708").ToString(), resultDevice.Identifier);
Assert.Equal("PublicKey", resultDevice.EncryptedPublicKey);
Assert.Equal("UserKey", resultDevice.EncryptedUserKey);
} }
[Fact] [Fact]

View File

@ -1,7 +1,9 @@
using Bit.Api.Auth.Controllers; using Bit.Api.Auth.Controllers;
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Api.Response.Accounts; using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -80,6 +82,57 @@ public class WebAuthnControllerTests
Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message); Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message);
} }
[Theory, BitAutoData]
public async Task AttestationOptions_RequireSsoPolicyNotApplicable_Succeeds(
SecretVerificationRequestModel requestModel, User user, SutProvider<WebAuthnController> sutProvider)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, default).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IPolicyService>().AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso).ReturnsForAnyArgs(false);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Protect(Arg.Any<WebAuthnCredentialCreateOptionsTokenable>()).Returns("token");
var result = await sutProvider.Sut.AttestationOptions(requestModel);
Assert.NotNull(result);
}
[Theory, BitAutoData]
public async Task AttestationOptions_WithPolicyRequirementsEnabled_CanUsePasskeyLoginFalse_ThrowsBadRequestException(
SecretVerificationRequestModel requestModel, User user, SutProvider<WebAuthnController> sutProvider)
{
// Arrange
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, default).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<RequireSsoPolicyRequirement>(user.Id)
.ReturnsForAnyArgs(new RequireSsoPolicyRequirement { CanUsePasskeyLogin = false });
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AttestationOptions(requestModel));
Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message);
}
[Theory, BitAutoData]
public async Task AttestationOptions_WithPolicyRequirementsEnabled_CanUsePasskeyLoginTrue_Succeeds(
SecretVerificationRequestModel requestModel, User user, SutProvider<WebAuthnController> sutProvider)
{
sutProvider.GetDependency<IUserService>().GetUserByPrincipalAsync(default).ReturnsForAnyArgs(user);
sutProvider.GetDependency<IUserService>().VerifySecretAsync(user, default).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<RequireSsoPolicyRequirement>(user.Id)
.ReturnsForAnyArgs(new RequireSsoPolicyRequirement { CanUsePasskeyLogin = true });
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Protect(Arg.Any<WebAuthnCredentialCreateOptionsTokenable>()).Returns("token");
var result = await sutProvider.Sut.AttestationOptions(requestModel);
Assert.NotNull(result);
}
#region Assertion Options #region Assertion Options
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task AssertionOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider<WebAuthnController> sutProvider) public async Task AssertionOptions_UserNotFound_ThrowsUnauthorizedAccessException(SecretVerificationRequestModel requestModel, SutProvider<WebAuthnController> sutProvider)
@ -211,6 +264,102 @@ public class WebAuthnControllerTests
Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message); Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message);
} }
[Theory, BitAutoData]
public async Task Post_RequireSsoPolicyNotApplicable_Succeeds(
WebAuthnLoginCredentialCreateRequestModel requestModel,
CredentialCreateOptions createOptions,
User user,
SutProvider<WebAuthnController> sutProvider)
{
// Arrange
var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions);
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey)
.Returns(true);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Unprotect(requestModel.Token)
.Returns(token);
sutProvider.GetDependency<IPolicyService>().AnyPoliciesApplicableToUserAsync(user.Id, PolicyType.RequireSso).ReturnsForAnyArgs(false);
// Act
await sutProvider.Sut.Post(requestModel);
// Assert
await sutProvider.GetDependency<IUserService>()
.Received(1)
.GetUserByPrincipalAsync(default);
await sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.Received(1)
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey);
}
[Theory, BitAutoData]
public async Task Post_WithPolicyRequirementsEnabled_CanUsePasskeyLoginFalse_ThrowsBadRequestException(
WebAuthnLoginCredentialCreateRequestModel requestModel,
CredentialCreateOptions createOptions,
User user,
SutProvider<WebAuthnController> sutProvider)
{
// Arrange
var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions);
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), false)
.Returns(true);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Unprotect(requestModel.Token)
.Returns(token);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<RequireSsoPolicyRequirement>(user.Id)
.ReturnsForAnyArgs(new RequireSsoPolicyRequirement { CanUsePasskeyLogin = false });
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.Post(requestModel));
Assert.Contains("Passkeys cannot be created for your account. SSO login is required", exception.Message);
}
[Theory, BitAutoData]
public async Task Post_WithPolicyRequirementsEnabled_CanUsePasskeyLoginTrue_Succeeds(
WebAuthnLoginCredentialCreateRequestModel requestModel,
CredentialCreateOptions createOptions,
User user,
SutProvider<WebAuthnController> sutProvider)
{
// Arrange
var token = new WebAuthnCredentialCreateOptionsTokenable(user, createOptions);
sutProvider.GetDependency<IUserService>()
.GetUserByPrincipalAsync(default)
.ReturnsForAnyArgs(user);
sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey)
.Returns(true);
sutProvider.GetDependency<IDataProtectorTokenFactory<WebAuthnCredentialCreateOptionsTokenable>>()
.Unprotect(requestModel.Token)
.Returns(token);
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.PolicyRequirements).ReturnsForAnyArgs(true);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<RequireSsoPolicyRequirement>(user.Id)
.ReturnsForAnyArgs(new RequireSsoPolicyRequirement { CanUsePasskeyLogin = true });
// Act
await sutProvider.Sut.Post(requestModel);
// Assert
await sutProvider.GetDependency<IUserService>()
.Received(1)
.GetUserByPrincipalAsync(default);
await sutProvider.GetDependency<ICreateWebAuthnLoginCredentialCommand>()
.Received(1)
.CreateWebAuthnLoginCredentialAsync(user, requestModel.Name, createOptions, Arg.Any<AuthenticatorAttestationRawResponse>(), requestModel.SupportsPrf, requestModel.EncryptedUserKey, requestModel.EncryptedPublicKey, requestModel.EncryptedPrivateKey);
}
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task Delete_UserNotFound_ThrowsUnauthorizedAccessException(Guid credentialId, SecretVerificationRequestModel requestModel, SutProvider<WebAuthnController> sutProvider) public async Task Delete_UserNotFound_ThrowsUnauthorizedAccessException(Guid credentialId, SecretVerificationRequestModel requestModel, SutProvider<WebAuthnController> sutProvider)
{ {

View File

@ -420,7 +420,7 @@ public class InviteOrganizationUserCommandTests
Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result); Assert.IsType<Success<ScimInviteOrganizationUsersResponse>>(result);
await sutProvider.GetDependency<IPaymentService>() await sutProvider.GetDependency<IPaymentService>()
.AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.SeatsRequiredToAdd); .AdjustSeatsAsync(organization, inviteOrganization.Plan, passwordManagerUpdate.UpdatedSeatTotal!.Value);
await orgRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal)); await orgRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(x => x.Seats == passwordManagerUpdate.UpdatedSeatTotal));

View File

@ -0,0 +1,104 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.Enums;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
[SutProviderCustomize]
public class RequireSsoPolicyRequirementFactoryTests
{
[Theory, BitAutoData]
public void CanUsePasskeyLogin_WithNoPolicies_ReturnsTrue(
SutProvider<RequireSsoPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create([]);
Assert.True(actual.CanUsePasskeyLogin);
}
[Theory]
[BitAutoData(OrganizationUserStatusType.Accepted)]
[BitAutoData(OrganizationUserStatusType.Confirmed)]
public void CanUsePasskeyLogin_WithoutExemptStatus_ReturnsFalse(
OrganizationUserStatusType userStatus,
SutProvider<RequireSsoPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create(
[
new PolicyDetails
{
PolicyType = PolicyType.RequireSso,
OrganizationUserStatus = userStatus
}
]);
Assert.False(actual.CanUsePasskeyLogin);
}
[Theory]
[BitAutoData(OrganizationUserStatusType.Revoked)]
[BitAutoData(OrganizationUserStatusType.Invited)]
public void CanUsePasskeyLogin_WithExemptStatus_ReturnsTrue(
OrganizationUserStatusType userStatus,
SutProvider<RequireSsoPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create(
[
new PolicyDetails
{
PolicyType = PolicyType.RequireSso,
OrganizationUserStatus = userStatus
}
]);
Assert.True(actual.CanUsePasskeyLogin);
}
[Theory, BitAutoData]
public void SsoRequired_WithNoPolicies_ReturnsFalse(
SutProvider<RequireSsoPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create([]);
Assert.False(actual.SsoRequired);
}
[Theory]
[BitAutoData(OrganizationUserStatusType.Revoked)]
[BitAutoData(OrganizationUserStatusType.Invited)]
[BitAutoData(OrganizationUserStatusType.Accepted)]
public void SsoRequired_WithoutExemptStatus_ReturnsFalse(
OrganizationUserStatusType userStatus,
SutProvider<RequireSsoPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create(
[
new PolicyDetails
{
PolicyType = PolicyType.RequireSso,
OrganizationUserStatus = userStatus
}
]);
Assert.False(actual.SsoRequired);
}
[Theory, BitAutoData]
public void SsoRequired_WithExemptStatus_ReturnsTrue(
SutProvider<RequireSsoPolicyRequirementFactory> sutProvider)
{
var actual = sutProvider.Sut.Create(
[
new PolicyDetails
{
PolicyType = PolicyType.RequireSso,
OrganizationUserStatus = OrganizationUserStatusType.Confirmed
}
]);
Assert.True(actual.SsoRequired);
}
}

View File

@ -0,0 +1,181 @@
using System.Text.Json;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using NSubstitute;
using Xunit;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class SlackEventHandlerTests
{
private readonly IOrganizationIntegrationConfigurationRepository _repository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
private readonly ISlackService _slackService = Substitute.For<ISlackService>();
private readonly string _channelId = "C12345";
private readonly string _channelId2 = "C67890";
private readonly string _token = "xoxb-test-token";
private readonly string _token2 = "xoxb-another-test-token";
private SutProvider<SlackEventHandler> GetSutProvider(
List<OrganizationIntegrationConfigurationDetails> integrationConfigurations)
{
_repository.GetConfigurationDetailsAsync(Arg.Any<Guid>(),
IntegrationType.Slack, Arg.Any<EventType>())
.Returns(integrationConfigurations);
return new SutProvider<SlackEventHandler>()
.SetDependency(_repository)
.SetDependency(_slackService)
.Create();
}
private List<OrganizationIntegrationConfigurationDetails> NoConfigurations()
{
return [];
}
private List<OrganizationIntegrationConfigurationDetails> OneConfiguration()
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = JsonSerializer.Serialize(new { token = _token });
config.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId });
config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#";
return [config];
}
private List<OrganizationIntegrationConfigurationDetails> TwoConfigurations()
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = JsonSerializer.Serialize(new { token = _token });
config.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId });
config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#";
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config2.Configuration = JsonSerializer.Serialize(new { token = _token2 });
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { channelId = _channelId2 });
config2.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#";
return [config, config2];
}
private List<OrganizationIntegrationConfigurationDetails> WrongConfiguration()
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = JsonSerializer.Serialize(new { });
config.IntegrationConfiguration = JsonSerializer.Serialize(new { });
config.Template = "Date: #Date#, Type: #Type#, UserId: #UserId#";
return [config];
}
[Theory, BitAutoData]
public async Task HandleEventAsync_NoConfigurations_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(NoConfigurations());
await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<ISlackService>().DidNotReceiveWithAnyArgs();
}
[Theory, BitAutoData]
public async Task HandleEventAsync_OneConfiguration_SendsEventViaSlackService(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(OneConfiguration());
await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_TwoConfigurations_SendsMultipleEvents(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(TwoConfigurations());
await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token)),
Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId))
);
sutProvider.GetDependency<ISlackService>().Received(1).SendSlackMessageByChannelIdAsync(
Arg.Is(AssertHelper.AssertPropertyEqual(_token2)),
Arg.Is(AssertHelper.AssertPropertyEqual(
$"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}")),
Arg.Is(AssertHelper.AssertPropertyEqual(_channelId2))
);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_WrongConfiguration_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(WrongConfiguration());
await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<ISlackService>().DidNotReceiveWithAnyArgs();
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_OneConfiguration_SendsEventsViaSlackService(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(OneConfiguration());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
var received = sutProvider.GetDependency<ISlackService>().ReceivedCalls();
using var calls = received.GetEnumerator();
Assert.Equal(eventMessages.Count, received.Count());
foreach (var eventMessage in eventMessages)
{
Assert.True(calls.MoveNext());
var arguments = calls.Current.GetArguments();
Assert.Equal(_token, arguments[0] as string);
Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}",
arguments[1] as string);
Assert.Equal(_channelId, arguments[2] as string);
}
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_TwoConfigurations_SendsMultipleEvents(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(TwoConfigurations());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
var received = sutProvider.GetDependency<ISlackService>().ReceivedCalls();
using var calls = received.GetEnumerator();
Assert.Equal(eventMessages.Count * 2, received.Count());
foreach (var eventMessage in eventMessages)
{
Assert.True(calls.MoveNext());
var arguments = calls.Current.GetArguments();
Assert.Equal(_token, arguments[0] as string);
Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}",
arguments[1] as string);
Assert.Equal(_channelId, arguments[2] as string);
Assert.True(calls.MoveNext());
var arguments2 = calls.Current.GetArguments();
Assert.Equal(_token2, arguments2[0] as string);
Assert.Equal($"Date: {eventMessage.Date}, Type: {eventMessage.Type}, UserId: {eventMessage.UserId}",
arguments2[1] as string);
Assert.Equal(_channelId2, arguments2[2] as string);
}
}
}

View File

@ -0,0 +1,344 @@
using System.Net;
using System.Text.Json;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.MockedHttpClient;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services;
[SutProviderCustomize]
public class SlackServiceTests
{
private readonly MockedHttpMessageHandler _handler;
private readonly HttpClient _httpClient;
private const string _token = "xoxb-test-token";
public SlackServiceTests()
{
_handler = new MockedHttpMessageHandler();
_httpClient = _handler.ToHttpClient();
}
private SutProvider<SlackService> GetSutProvider()
{
var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(SlackService.HttpClientName).Returns(_httpClient);
var globalSettings = Substitute.For<GlobalSettings>();
globalSettings.Slack.ApiBaseUrl.Returns("https://slack.com/api");
return new SutProvider<SlackService>()
.SetDependency(clientFactory)
.SetDependency(globalSettings)
.Create();
}
[Fact]
public async Task GetChannelIdsAsync_ReturnsCorrectChannelIds()
{
var response = JsonSerializer.Serialize(
new
{
ok = true,
channels =
new[] {
new { id = "C12345", name = "general" },
new { id = "C67890", name = "random" }
},
response_metadata = new { next_cursor = "" }
}
);
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(response));
var sutProvider = GetSutProvider();
var channelNames = new List<string> { "general", "random" };
var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames);
Assert.Equal(2, result.Count);
Assert.Contains("C12345", result);
Assert.Contains("C67890", result);
}
[Fact]
public async Task GetChannelIdsAsync_WithPagination_ReturnsCorrectChannelIds()
{
var firstPageResponse = JsonSerializer.Serialize(
new
{
ok = true,
channels = new[] { new { id = "C12345", name = "general" } },
response_metadata = new { next_cursor = "next_cursor_value" }
}
);
var secondPageResponse = JsonSerializer.Serialize(
new
{
ok = true,
channels = new[] { new { id = "C67890", name = "random" } },
response_metadata = new { next_cursor = "" }
}
);
_handler.When("https://slack.com/api/conversations.list?types=public_channel%2cprivate_channel&limit=1000")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(firstPageResponse));
_handler.When("https://slack.com/api/conversations.list?types=public_channel%2cprivate_channel&limit=1000&cursor=next_cursor_value")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(secondPageResponse));
var sutProvider = GetSutProvider();
var channelNames = new List<string> { "general", "random" };
var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames);
Assert.Equal(2, result.Count);
Assert.Contains("C12345", result);
Assert.Contains("C67890", result);
}
[Fact]
public async Task GetChannelIdsAsync_ApiError_ReturnsEmptyResult()
{
var errorResponse = JsonSerializer.Serialize(
new { ok = false, error = "rate_limited" }
);
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.TooManyRequests)
.WithContent(new StringContent(errorResponse));
var sutProvider = GetSutProvider();
var channelNames = new List<string> { "general", "random" };
var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames);
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdsAsync_NoChannelsFound_ReturnsEmptyResult()
{
var emptyResponse = JsonSerializer.Serialize(
new
{
ok = true,
channels = Array.Empty<string>(),
response_metadata = new { next_cursor = "" }
});
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(emptyResponse));
var sutProvider = GetSutProvider();
var channelNames = new List<string> { "general", "random" };
var result = await sutProvider.Sut.GetChannelIdsAsync(_token, channelNames);
Assert.Empty(result);
}
[Fact]
public async Task GetChannelIdAsync_ReturnsCorrectChannelId()
{
var sutProvider = GetSutProvider();
var response = new
{
ok = true,
channels = new[]
{
new { id = "C12345", name = "general" },
new { id = "C67890", name = "random" }
},
response_metadata = new { next_cursor = "" }
};
_handler.When(HttpMethod.Get)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(response)));
var result = await sutProvider.Sut.GetChannelIdAsync(_token, "general");
Assert.Equal("C12345", result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ReturnsCorrectDmChannelId()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
var userId = "U12345";
var dmChannelId = "D67890";
var userResponse = new
{
ok = true,
user = new { id = userId }
};
var dmResponse = new
{
ok = true,
channel = new { id = dmChannelId }
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(userResponse)));
_handler.When("https://slack.com/api/conversations.open")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(dmResponse)));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(dmChannelId, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorDmResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
var userId = "U12345";
var userResponse = new
{
ok = true,
user = new { id = userId }
};
var dmResponse = new
{
ok = false,
error = "An error occured"
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(userResponse)));
_handler.When("https://slack.com/api/conversations.open")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(dmResponse)));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task GetDmChannelByEmailAsync_ApiErrorUserResponse_ReturnsEmptyString()
{
var sutProvider = GetSutProvider();
var email = "user@example.com";
var userResponse = new
{
ok = false,
error = "An error occured"
};
_handler.When($"https://slack.com/api/users.lookupByEmail?email={email}")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(JsonSerializer.Serialize(userResponse)));
var result = await sutProvider.Sut.GetDmChannelByEmailAsync(_token, email);
Assert.Equal(string.Empty, result);
}
[Fact]
public void GetRedirectUrl_ReturnsCorrectUrl()
{
var sutProvider = GetSutProvider();
var ClientId = sutProvider.GetDependency<GlobalSettings>().Slack.ClientId;
var Scopes = sutProvider.GetDependency<GlobalSettings>().Slack.Scopes;
var redirectUrl = "https://example.com/callback";
var expectedUrl = $"https://slack.com/oauth/v2/authorize?client_id={ClientId}&scope={Scopes}&redirect_uri={redirectUrl}";
var result = sutProvider.Sut.GetRedirectUrl(redirectUrl);
Assert.Equal(expectedUrl, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_ReturnsAccessToken_WhenSuccessful()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new
{
ok = true,
access_token = "test-access-token"
});
_handler.When("https://slack.com/api/oauth.v2.access")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal("test-access-token", result);
}
[Fact]
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenErrorResponse()
{
var sutProvider = GetSutProvider();
var jsonResponse = JsonSerializer.Serialize(new
{
ok = false,
error = "invalid_code"
});
_handler.When("https://slack.com/api/oauth.v2.access")
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(jsonResponse));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task ObtainTokenViaOAuth_ReturnsEmptyString_WhenHttpCallFails()
{
var sutProvider = GetSutProvider();
_handler.When("https://slack.com/api/oauth.v2.access")
.RespondWith(HttpStatusCode.InternalServerError)
.WithContent(new StringContent(string.Empty));
var result = await sutProvider.Sut.ObtainTokenViaOAuth("test-code", "https://example.com/callback");
Assert.Equal(string.Empty, result);
}
[Fact]
public async Task SendSlackMessageByChannelId_Sends_Correct_Message()
{
var sutProvider = GetSutProvider();
var channelId = "C12345";
var message = "Hello, Slack!";
_handler.When(HttpMethod.Post)
.RespondWith(HttpStatusCode.OK)
.WithContent(new StringContent(string.Empty));
await sutProvider.Sut.SendSlackMessageByChannelIdAsync(_token, message, channelId);
Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
Assert.NotNull(request);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.NotNull(request.Headers.Authorization);
Assert.Equal($"Bearer {_token}", request.Headers.Authorization.ToString());
Assert.NotNull(request.Content);
var returned = (await request.Content.ReadAsStringAsync());
var json = JsonDocument.Parse(returned);
Assert.Equal(message, json.RootElement.GetProperty("text").GetString() ?? string.Empty);
Assert.Equal(channelId, json.RootElement.GetProperty("channel").GetString() ?? string.Empty);
}
}

View File

@ -1,6 +1,10 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json;
using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@ -8,7 +12,6 @@ using Bit.Test.Common.Helpers;
using Bit.Test.Common.MockedHttpClient; using Bit.Test.Common.MockedHttpClient;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services; namespace Bit.Core.Test.Services;
@ -16,9 +19,18 @@ namespace Bit.Core.Test.Services;
public class WebhookEventHandlerTests public class WebhookEventHandlerTests
{ {
private readonly MockedHttpMessageHandler _handler; private readonly MockedHttpMessageHandler _handler;
private HttpClient _httpClient; private readonly HttpClient _httpClient;
private const string _template =
"""
{
"Date": "#Date#",
"Type": "#Type#",
"UserId": "#UserId#"
}
""";
private const string _webhookUrl = "http://localhost/test/event"; private const string _webhookUrl = "http://localhost/test/event";
private const string _webhookUrl2 = "http://localhost/another/event";
public WebhookEventHandlerTests() public WebhookEventHandlerTests()
{ {
@ -29,57 +41,195 @@ public class WebhookEventHandlerTests
_httpClient = _handler.ToHttpClient(); _httpClient = _handler.ToHttpClient();
} }
public SutProvider<WebhookEventHandler> GetSutProvider() private SutProvider<WebhookEventHandler> GetSutProvider(
List<OrganizationIntegrationConfigurationDetails> configurations)
{ {
var clientFactory = Substitute.For<IHttpClientFactory>(); var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient); clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient);
var globalSettings = new GlobalSettings(); var repository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
globalSettings.EventLogging.WebhookUrl = _webhookUrl; repository.GetConfigurationDetailsAsync(Arg.Any<Guid>(),
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
return new SutProvider<WebhookEventHandler>() return new SutProvider<WebhookEventHandler>()
.SetDependency(globalSettings) .SetDependency(repository)
.SetDependency(clientFactory) .SetDependency(clientFactory)
.Create(); .Create();
} }
[Theory, BitAutoData] private static List<OrganizationIntegrationConfigurationDetails> NoConfigurations()
public async Task HandleEventAsync_PostsEventToUrl(EventMessage eventMessage)
{ {
var sutProvider = GetSutProvider(); return [];
}
private static List<OrganizationIntegrationConfigurationDetails> OneConfiguration()
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = null;
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl });
config.Template = _template;
return [config];
}
private static List<OrganizationIntegrationConfigurationDetails> TwoConfigurations()
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = null;
config.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl });
config.Template = _template;
var config2 = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config2.Configuration = null;
config2.IntegrationConfiguration = JsonSerializer.Serialize(new { url = _webhookUrl2 });
config2.Template = _template;
return [config, config2];
}
private static List<OrganizationIntegrationConfigurationDetails> WrongConfiguration()
{
var config = Substitute.For<OrganizationIntegrationConfigurationDetails>();
config.Configuration = null;
config.IntegrationConfiguration = JsonSerializer.Serialize(new { error = string.Empty });
config.Template = _template;
return [config];
}
[Theory, BitAutoData]
public async Task HandleEventAsync_NoConfigurations_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(NoConfigurations());
await sutProvider.Sut.HandleEventAsync(eventMessage); await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual<string>(WebhookEventHandler.HttpClientName)) Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
); );
Assert.Single(_handler.CapturedRequests); Assert.Empty(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
Assert.NotNull(request);
var returned = await request.Content.ReadFromJsonAsync<EventMessage>();
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(_webhookUrl, request.RequestUri.ToString());
AssertHelper.AssertPropertyEqual(eventMessage, returned, new[] { "IdempotencyId" });
} }
[Theory, BitAutoData] [Theory, BitAutoData]
public async Task HandleEventManyAsync_PostsEventsToUrl(IEnumerable<EventMessage> eventMessages) public async Task HandleEventAsync_OneConfiguration_PostsEventToUrl(EventMessage eventMessage)
{ {
var sutProvider = GetSutProvider(); var sutProvider = GetSutProvider(OneConfiguration());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages); await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient( sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual<string>(WebhookEventHandler.HttpClientName)) Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
); );
Assert.Single(_handler.CapturedRequests); Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0]; var request = _handler.CapturedRequests[0];
Assert.NotNull(request); Assert.NotNull(request);
var returned = request.Content.ReadFromJsonAsAsyncEnumerable<EventMessage>(); var returned = await request.Content.ReadFromJsonAsync<MockEvent>();
var expected = MockEvent.From(eventMessage);
Assert.Equal(HttpMethod.Post, request.Method); Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(_webhookUrl, request.RequestUri.ToString()); Assert.Equal(_webhookUrl, request.RequestUri.ToString());
AssertHelper.AssertPropertyEqual(eventMessages, returned, new[] { "IdempotencyId" }); AssertHelper.AssertPropertyEqual(expected, returned);
}
[Theory, BitAutoData]
public async Task HandleEventAsync_WrongConfigurations_DoesNothing(EventMessage eventMessage)
{
var sutProvider = GetSutProvider(WrongConfiguration());
await sutProvider.Sut.HandleEventAsync(eventMessage);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
);
Assert.Empty(_handler.CapturedRequests);
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_NoConfigurations_DoesNothing(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(NoConfigurations());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
);
Assert.Empty(_handler.CapturedRequests);
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_OneConfiguration_PostsEventsToUrl(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(OneConfiguration());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
);
Assert.Equal(eventMessages.Count, _handler.CapturedRequests.Count);
var index = 0;
foreach (var request in _handler.CapturedRequests)
{
Assert.NotNull(request);
var returned = await request.Content.ReadFromJsonAsync<MockEvent>();
var expected = MockEvent.From(eventMessages[index]);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(_webhookUrl, request.RequestUri.ToString());
AssertHelper.AssertPropertyEqual(expected, returned);
index++;
}
}
[Theory, BitAutoData]
public async Task HandleManyEventsAsync_TwoConfigurations_PostsEventsToMultipleUrls(List<EventMessage> eventMessages)
{
var sutProvider = GetSutProvider(TwoConfigurations());
await sutProvider.Sut.HandleManyEventsAsync(eventMessages);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
);
using var capturedRequests = _handler.CapturedRequests.GetEnumerator();
Assert.Equal(eventMessages.Count * 2, _handler.CapturedRequests.Count);
foreach (var eventMessage in eventMessages)
{
var expected = MockEvent.From(eventMessage);
Assert.True(capturedRequests.MoveNext());
var request = capturedRequests.Current;
Assert.NotNull(request);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(_webhookUrl, request.RequestUri.ToString());
var returned = await request.Content.ReadFromJsonAsync<MockEvent>();
AssertHelper.AssertPropertyEqual(expected, returned);
Assert.True(capturedRequests.MoveNext());
request = capturedRequests.Current;
Assert.NotNull(request);
Assert.Equal(HttpMethod.Post, request.Method);
Assert.Equal(_webhookUrl2, request.RequestUri.ToString());
returned = await request.Content.ReadFromJsonAsync<MockEvent>();
AssertHelper.AssertPropertyEqual(expected, returned);
}
}
}
public class MockEvent(string date, string type, string userId)
{
public string Date { get; set; } = date;
public string Type { get; set; } = type;
public string UserId { get; set; } = userId;
public static MockEvent From(EventMessage eventMessage)
{
return new MockEvent(
eventMessage.Date.ToString(),
eventMessage.Type.ToString(),
eventMessage.UserId.ToString()
);
} }
} }

View File

@ -0,0 +1,92 @@
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Models.Data;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Core.Test.AdminConsole.Utilities;
public class IntegrationTemplateProcessorTests
{
[Theory, BitAutoData]
public void ReplaceTokens_ReplacesSingleToken(EventMessage eventMessage)
{
var template = "Event #Type# occurred.";
var expected = $"Event {eventMessage.Type} occurred.";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage);
Assert.Equal(expected, result);
}
[Theory, BitAutoData]
public void ReplaceTokens_ReplacesMultipleTokens(EventMessage eventMessage)
{
var template = "Event #Type#, User (id: #UserId#).";
var expected = $"Event {eventMessage.Type}, User (id: {eventMessage.UserId}).";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage);
Assert.Equal(expected, result);
}
[Theory, BitAutoData]
public void ReplaceTokens_LeavesUnknownTokensUnchanged(EventMessage eventMessage)
{
var template = "Event #Type#, User (id: #UserId#), Details: #UnknownKey#.";
var expected = $"Event {eventMessage.Type}, User (id: {eventMessage.UserId}), Details: #UnknownKey#.";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage);
Assert.Equal(expected, result);
}
[Theory, BitAutoData]
public void ReplaceTokens_WithNullProperty_LeavesTokenUnchanged(EventMessage eventMessage)
{
eventMessage.UserId = null;
var template = "Event #Type#, User (id: #UserId#).";
var expected = $"Event {eventMessage.Type}, User (id: #UserId#).";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage);
Assert.Equal(expected, result);
}
[Theory, BitAutoData]
public void ReplaceTokens_TokensWithNonmatchingCase_LeavesTokensUnchanged(EventMessage eventMessage)
{
var template = "Event #type#, User (id: #UserId#).";
var expected = $"Event #type#, User (id: {eventMessage.UserId}).";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage);
Assert.Equal(expected, result);
}
[Theory, BitAutoData]
public void ReplaceTokens_NoTokensPresent_ReturnsOriginalString(EventMessage eventMessage)
{
var template = "System is running normally.";
var expected = "System is running normally.";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, eventMessage);
Assert.Equal(expected, result);
}
[Theory, BitAutoData]
public void ReplaceTokens_TemplateIsEmpty_ReturnsOriginalString(EventMessage eventMessage)
{
var emptyTemplate = "";
var expectedEmpty = "";
Assert.Equal(expectedEmpty, IntegrationTemplateProcessor.ReplaceTokens(emptyTemplate, eventMessage));
Assert.Null(IntegrationTemplateProcessor.ReplaceTokens(null, eventMessage));
}
[Fact]
public void ReplaceTokens_DataObjectIsNull_ReturnsOriginalString()
{
var template = "Event #Type#, User (id: #UserId#).";
var expected = "Event #Type#, User (id: #UserId#).";
var result = IntegrationTemplateProcessor.ReplaceTokens(template, null);
Assert.Equal(expected, result);
}
}

View File

@ -1,9 +1,6 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Bit.Core.Models.Business; using Bit.Core.Models.Business;
using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
using Xunit; using Xunit;
@ -12,22 +9,6 @@ namespace Bit.Core.Test.Models.Business;
public class OrganizationLicenseTests public class OrganizationLicenseTests
{ {
/// <summary>
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
/// its hash does not change.
/// This guards against the risk that properties added in later versions are accidentally included in the hash,
/// or that a property is added without incrementing the version number.
/// </summary>
[Theory]
[BitAutoData(OrganizationLicense.CurrentLicenseFileVersion)] // Previous version (this property is 1 behind)
[BitAutoData(OrganizationLicense.CurrentLicenseFileVersion + 1)] // Current version
public void OrganizationLicense_LoadFromDisk_HashDoesNotChange(int licenseVersion)
{
var license = OrganizationLicenseFileFixtures.GetVersion(licenseVersion);
// Compare the hash loaded from the json to the hash generated by the current class
Assert.Equal(Convert.FromBase64String(license.Hash), license.ComputeHash());
}
/// <summary> /// <summary>
/// Verifies that when the license file is loaded from disk using the current OrganizationLicense class, /// Verifies that when the license file is loaded from disk using the current OrganizationLicense class,
@ -52,22 +33,4 @@ public class OrganizationLicenseTests
}); });
Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings)); Assert.True(license.VerifyData(organization, claimsPrincipal, globalSettings));
} }
/// <summary>
/// Helper used to generate a new json string to be added in OrganizationLicenseFileFixtures.
/// Uncomment [Fact], run the test and copy the value of the `result` variable into OrganizationLicenseFileFixtures,
/// following the instructions in that class.
/// </summary>
// [Fact]
private void GenerateLicenseFileJsonString()
{
var organization = OrganizationLicenseFileFixtures.OrganizationFactory();
var licensingService = Substitute.For<ILicensingService>();
var installationId = new Guid(OrganizationLicenseFileFixtures.InstallationId);
var license = new OrganizationLicense(organization, null, installationId, licensingService);
var result = JsonSerializer.Serialize(license, JsonHelpers.Indented).Replace("\"", "'");
// Put a break after this line, then copy and paste the value of `result` into OrganizationLicenseFileFixtures
}
} }

View File

@ -41,7 +41,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).ReturnsNull(); sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).ReturnsNull();
var exception = await Assert.ThrowsAsync<BadRequestException>(() => var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, false, null));
Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message); Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs() await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -55,7 +55,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, default, null)); sutProvider.Sut.CreateSponsorshipAsync(null, orgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, default, false, null));
Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message); Assert.Contains("Cannot offer a Families Organization Sponsorship to yourself. Choose a different email.", exception.Message);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs() await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -72,7 +72,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, false, null));
Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message); Assert.Contains("Specified Organization cannot sponsor other organizations.", exception.Message);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs() await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -91,7 +91,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user); sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, null)); sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, PlanSponsorshipType.FamiliesForEnterprise, default, default, false, null));
Assert.Contains("Only confirmed users can sponsor other organizations.", exception.Message); Assert.Contains("Only confirmed users can sponsor other organizations.", exception.Message);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs() await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -115,7 +115,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(orgUser.UserId.Value); sutProvider.GetDependency<ICurrentContext>().UserId.Returns(orgUser.UserId.Value);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType!.Value, null, null, null)); sutProvider.Sut.CreateSponsorshipAsync(org, orgUser, sponsorship.PlanSponsorshipType!.Value, null, null, false, null));
Assert.Contains("Can only sponsor one organization per Organization User.", exception.Message); Assert.Contains("Can only sponsor one organization per Organization User.", exception.Message);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs() await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -147,7 +147,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actual = await Assert.ThrowsAsync<BadRequestException>(async () => var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null)); await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null));
Assert.Equal("Only confirmed users can sponsor other organizations.", actual.Message); Assert.Equal("Only confirmed users can sponsor other organizations.", actual.Message);
} }
@ -170,7 +170,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null); PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
var expectedSponsorship = new OrganizationSponsorship var expectedSponsorship = new OrganizationSponsorship
{ {
@ -209,7 +209,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actualException = await Assert.ThrowsAsync<Exception>(() => var actualException = await Assert.ThrowsAsync<Exception>(() =>
sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null)); PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null));
Assert.Same(expectedException, actualException); Assert.Same(expectedException, actualException);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1) await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
@ -244,9 +244,9 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actual = await Assert.ThrowsAsync<UnauthorizedAccessException>(async () => var actual = await Assert.ThrowsAsync<UnauthorizedAccessException>(async () =>
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes: null)); PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, null));
Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message); Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization", actual.Message);
} }
[Theory] [Theory]
@ -278,9 +278,9 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actual = await Assert.ThrowsAsync<UnauthorizedAccessException>(async () => var actual = await Assert.ThrowsAsync<UnauthorizedAccessException>(async () =>
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes: null)); PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, null));
Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization.", actual.Message); Assert.Equal("You do not have permissions to send sponsorships on behalf of the organization", actual.Message);
} }
[Theory] [Theory]
@ -312,7 +312,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
]); ]);
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser, var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes); PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
var expectedSponsorship = new OrganizationSponsorship var expectedSponsorship = new OrganizationSponsorship
@ -330,6 +330,6 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
Assert.True(SponsorshipValidator(expectedSponsorship, actual)); Assert.True(SponsorshipValidator(expectedSponsorship, actual));
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1) await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
.UpsertAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship))); .CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
} }
} }

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