1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-25 06:42:22 -05:00

Merge branch 'main' into feature/phishing-detection

This commit is contained in:
Conner Turnbull 2025-04-24 12:14:39 -04:00 committed by GitHub
commit 866144d00e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
122 changed files with 32301 additions and 376 deletions

View File

@ -9,6 +9,19 @@
"nuget",
],
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",
matchManagers: ["dockerfile"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,103 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[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,71 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
#nullable enable
namespace Bit.Api.AdminConsole.Controllers;
[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 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))
{
throw new BadRequestException("Incorrect password");

View File

@ -0,0 +1,77 @@
using System.Text.Json;
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.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[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

@ -4,6 +4,7 @@ using Bit.Api.Auth.Models.Response.WebAuthn;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Api.Response.Accounts;
@ -31,6 +32,8 @@ public class WebAuthnController : Controller
private readonly ICreateWebAuthnLoginCredentialCommand _createWebAuthnLoginCredentialCommand;
private readonly IAssertWebAuthnLoginCredentialCommand _assertWebAuthnLoginCredentialCommand;
private readonly IGetWebAuthnLoginCredentialAssertionOptionsCommand _getWebAuthnLoginCredentialAssertionOptionsCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IFeatureService _featureService;
public WebAuthnController(
IUserService userService,
@ -41,7 +44,9 @@ public class WebAuthnController : Controller
IGetWebAuthnLoginCredentialCreateOptionsCommand getWebAuthnLoginCredentialCreateOptionsCommand,
ICreateWebAuthnLoginCredentialCommand createWebAuthnLoginCredentialCommand,
IAssertWebAuthnLoginCredentialCommand assertWebAuthnLoginCredentialCommand,
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand)
IGetWebAuthnLoginCredentialAssertionOptionsCommand getWebAuthnLoginCredentialAssertionOptionsCommand,
IPolicyRequirementQuery policyRequirementQuery,
IFeatureService featureService)
{
_userService = userService;
_policyService = policyService;
@ -52,7 +57,8 @@ public class WebAuthnController : Controller
_createWebAuthnLoginCredentialCommand = createWebAuthnLoginCredentialCommand;
_assertWebAuthnLoginCredentialCommand = assertWebAuthnLoginCredentialCommand;
_getWebAuthnLoginCredentialAssertionOptionsCommand = getWebAuthnLoginCredentialAssertionOptionsCommand;
_policyRequirementQuery = policyRequirementQuery;
_featureService = featureService;
}
[HttpGet("")]
@ -68,7 +74,7 @@ public class WebAuthnController : Controller
public async Task<WebAuthnCredentialCreateOptionsResponseModel> AttestationOptions([FromBody] SecretVerificationRequestModel model)
{
var user = await VerifyUserAsync(model);
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id);
await ValidateIfUserCanUsePasskeyLogin(user.Id);
var options = await _getWebAuthnLoginCredentialCreateOptionsCommand.GetWebAuthnLoginCredentialCreateOptionsAsync(user);
var tokenable = new WebAuthnCredentialCreateOptionsTokenable(user, options);
@ -101,7 +107,7 @@ public class WebAuthnController : Controller
public async Task Post([FromBody] WebAuthnLoginCredentialCreateRequestModel model)
{
var user = await GetUserAsync();
await ValidateRequireSsoPolicyDisabledOrNotApplicable(user.Id);
await ValidateIfUserCanUsePasskeyLogin(user.Id);
var tokenable = _createOptionsDataProtector.Unprotect(model.Token);
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()]
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 (model.SponsoringUserId.HasValue)
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new NotFoundException();
throw new BadRequestException();
}
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(
sponsoringOrg,
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, targetUser),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
model.PlanSponsorshipType,
model.SponsoredEmail,
model.FriendlyName,
model.IsAdminInitiated.GetValueOrDefault(),
model.Notes);
await _sendSponsorshipOfferCommand.SendSponsorshipOfferAsync(sponsorship, sponsoringOrg.Name);
}

View File

@ -128,6 +128,7 @@ public class DevicesController : Controller
}
[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)
{
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 (model.SponsoringUserId.HasValue)
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new NotFoundException();
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
@ -60,8 +60,12 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
await _offerSponsorshipCommand.CreateSponsorshipAsync(
await _organizationRepository.GetByIdAsync(sponsoringOrgId),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, model.SponsoringUserId ?? _currentContext.UserId ?? default),
model.PlanSponsorshipType, model.SponsoredEmail, model.FriendlyName, model.Notes);
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),
model.PlanSponsorshipType,
model.SponsoredEmail,
model.FriendlyName,
model.IsAdminInitiated.GetValueOrDefault(),
model.Notes);
}
[HttpDelete("{sponsoringOrgId}")]

View File

@ -17,11 +17,7 @@ public class OrganizationSponsorshipCreateRequestModel
[StringLength(256)]
public string FriendlyName { get; set; }
/// <summary>
/// (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; }
public bool? IsAdminInitiated { get; set; }
[EncryptedString]
[EncryptedStringLength(512)]

View File

@ -2,6 +2,7 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Api.Models.Response;
@ -21,6 +22,8 @@ public class DeviceResponseModel : ResponseModel
Identifier = device.Identifier;
CreationDate = device.CreationDate;
IsTrusted = device.IsTrusted();
EncryptedUserKey = device.EncryptedUserKey;
EncryptedPublicKey = device.EncryptedPublicKey;
}
public Guid Id { get; set; }
@ -29,4 +32,10 @@ public class DeviceResponseModel : ResponseModel
public string Identifier { get; set; }
public DateTime CreationDate { 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 Stripe;
using Bit.Core.Utilities;
using Duende.IdentityModel;
using IdentityModel;
using System.Globalization;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request;
@ -27,8 +27,10 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions;
using Bit.Core.Tools.Entities;
using Bit.Core.Vault.Entities;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.ImportFeatures;
using Bit.Core.Tools.ReportFeatures;
using Bit.Core.Auth.Models.Api.Request;
@ -224,6 +226,19 @@ public class Startup
{
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(

View File

@ -1,5 +1,7 @@
using Bit.Billing.Constants;
using Bit.Core;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Services;
@ -15,19 +17,22 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
private readonly IStripeFacade _stripeFacade;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IFeatureService _featureService;
private readonly IProviderRepository _providerRepository;
public PaymentMethodAttachedHandler(
ILogger<PaymentMethodAttachedHandler> logger,
IStripeEventService stripeEventService,
IStripeFacade stripeFacade,
IStripeEventUtilityService stripeEventUtilityService,
IFeatureService featureService)
IFeatureService featureService,
IProviderRepository providerRepository)
{
_logger = logger;
_stripeEventService = stripeEventService;
_stripeFacade = stripeFacade;
_stripeEventUtilityService = stripeEventUtilityService;
_featureService = featureService;
_providerRepository = providerRepository;
}
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,
* 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)
{
@ -106,6 +117,7 @@ public class PaymentMethodAttachedHandler : IPaymentMethodAttachedHandler
customer.Id);
}
}
}
var unpaidSubscriptions = subscriptions?.Data.Where(subscription =>
subscription.Status == StripeConstants.SubscriptionStatus.Unpaid).ToList();

View File

@ -2,6 +2,8 @@
public enum IntegrationType : int
{
Slack = 1,
Webhook = 2,
CloudBillingSync = 1,
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)
{
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.
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd;
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove);
await paymentService.AdjustSeatsAsync(organization,
validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value);
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)
{
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.SeatsRequiredToAdd);
await paymentService.AdjustSeatsAsync(organization,
validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
@ -295,4 +294,5 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
});
}
}
}

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>, ResetPasswordPolicyRequirementFactory>();
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>> GetManyByOrganizationAsync(Guid organizationId, OrganizationUserType? type);
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<ICollection<string>> SelectKnownEmailsAsync(Guid organizationId, IEnumerable<string> emails, bool onlyRegisteredUsers);
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.Settings;
using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
namespace Bit.Core.Services;
public class WebhookEventHandler(
IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings)
IOrganizationIntegrationConfigurationRepository configurationRepository)
: IEventMessageHandler
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl;
public const string HttpClientName = "WebhookEventHandlerHttpClient";
public async Task HandleEventAsync(EventMessage eventMessage)
{
var content = JsonContent.Create(eventMessage);
var response = await _httpClient.PostAsync(_webhookUrl, content);
var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
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();
}
}
public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{
var content = JsonContent.Create(eventMessages);
var response = await _httpClient.PostAsync(_webhookUrl, content);
response.EnsureSuccessStatusCode();
foreach (var eventMessage in eventMessages)
{
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.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Utilities;
namespace Bit.Core.Auth.Models.Api.Response;
@ -19,6 +20,8 @@ public class DeviceAuthRequestResponseModel : ResponseModel
Identifier = deviceAuthDetails.Identifier,
CreationDate = deviceAuthDetails.CreationDate,
IsTrusted = deviceAuthDetails.IsTrusted,
EncryptedPublicKey = deviceAuthDetails.EncryptedPublicKey,
EncryptedUserKey = deviceAuthDetails.EncryptedUserKey
};
if (deviceAuthDetails.AuthRequestId != null && deviceAuthDetails.AuthRequestCreatedAt != null)
@ -39,6 +42,12 @@ public class DeviceAuthRequestResponseModel : ResponseModel
public string Identifier { get; set; }
public DateTime CreationDate { 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; }

View File

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

View File

@ -112,7 +112,6 @@ public static class FeatureFlagKeys
/* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-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 DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
@ -176,6 +175,7 @@ public static class FeatureFlagKeys
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting";
public const string AndroidChromeAutofill = "android-chrome-autofill";
/* Platform Team */
public const string PersistPopupView = "persist-popup-view";
@ -189,7 +189,6 @@ public static class FeatureFlagKeys
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 ExportAttachments = "export-attachments";
/* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
@ -216,9 +215,6 @@ public static class FeatureFlagKeys
public static Dictionary<string, string> GetLocalOverrideFlagValues()
{
// place overriding values when needed locally (offline), or return null
return new Dictionary<string, string>()
{
{ DuoRedirect, "true" },
};
return null;
}
}

View File

@ -52,7 +52,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.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="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />

View File

@ -14,11 +14,17 @@ namespace Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnte
public class CreateSponsorshipCommand(
ICurrentContext currentContext,
IOrganizationSponsorshipRepository organizationSponsorshipRepository,
IUserService userService) : ICreateSponsorshipCommand
IUserService userService,
IOrganizationService organizationService) : ICreateSponsorshipCommand
{
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrganization,
OrganizationUser sponsoringMember, PlanSponsorshipType sponsorshipType, string sponsoredEmail,
string friendlyName, string notes)
public async Task<OrganizationSponsorship> CreateSponsorshipAsync(
Organization sponsoringOrganization,
OrganizationUser sponsoringMember,
PlanSponsorshipType sponsorshipType,
string sponsoredEmail,
string friendlyName,
bool isAdminInitiated,
string notes)
{
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.");
}
var sponsorship = new OrganizationSponsorship();
sponsorship.SponsoringOrganizationId = sponsoringOrganization.Id;
sponsorship.SponsoringOrganizationUserId = sponsoringMember.Id;
sponsorship.FriendlyName = friendlyName;
sponsorship.OfferedToEmail = sponsoredEmail;
sponsorship.PlanSponsorshipType = sponsorshipType;
if (isAdminInitiated)
{
ValidateAdminInitiatedSponsorship(sponsoringOrganization);
}
var sponsorship = new OrganizationSponsorship
{
SponsoringOrganizationId = sponsoringOrganization.Id,
SponsoringOrganizationUserId = sponsoringMember.Id,
FriendlyName = friendlyName,
OfferedToEmail = sponsoredEmail,
PlanSponsorshipType = sponsorshipType,
IsAdminInitiated = isAdminInitiated,
Notes = notes
};
if (existingOrgSponsorship != null)
{
@ -61,35 +76,22 @@ public class CreateSponsorshipCommand(
sponsorship.Id = existingOrgSponsorship.Id;
}
var isAdminInitiated = false;
if (currentContext.UserId != sponsoringMember.UserId)
if (isAdminInitiated && sponsoringOrganization.Seats.HasValue)
{
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.");
await organizationService.AutoAddSeatsAsync(sponsoringOrganization, 1);
}
if (!sponsoringOrganization.UseAdminSponsoredFamilies)
{
throw new BadRequestException("Sponsoring organization cannot sponsor other Family organizations.");
}
isAdminInitiated = true;
}
sponsorship.IsAdminInitiated = isAdminInitiated;
sponsorship.Notes = notes;
try
{
if (isAdminInitiated)
{
await organizationSponsorshipRepository.CreateAsync(sponsorship);
}
else
{
await organizationSponsorshipRepository.UpsertAsync(sponsorship);
}
return sponsorship;
}
catch
@ -101,4 +103,24 @@ public class CreateSponsorshipCommand(
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
{
Task<OrganizationSponsorship> CreateSponsorshipAsync(Organization sponsoringOrg, OrganizationUser sponsoringOrgUser,
PlanSponsorshipType sponsorshipType, string sponsoredEmail, string friendlyName, string notes);
Task<OrganizationSponsorship> CreateSponsorshipAsync(
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.Settings;
using Bit.Core.Utilities;
using Duende.IdentityModel;
using IdentityModel;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
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 MySql { get; set; } = new SqlSettings();
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 MailSettings Mail { get; set; } = new MailSettings();
public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings();
@ -272,10 +273,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 AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
public virtual string WebhookUrl { get; set; }
public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings();
public class AzureServiceBusSettings
@ -284,6 +292,7 @@ public class GlobalSettings : IGlobalSettings
private string _topicName;
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 string ConnectionString
@ -308,6 +317,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue";
public virtual string WebhookQueueName { get; set; } = "events-webhook-queue";
public virtual string SlackQueueName { get; set; } = "events-slack-queue";
public string HostName
{

View File

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

View File

@ -1,12 +1,13 @@
using System.Globalization;
using Bit.Core.AdminConsole.Services.Implementations;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Context;
using Bit.Core.IdentityServer;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities;
using Duende.IdentityModel;
using IdentityModel;
namespace Bit.Events;
@ -117,11 +118,27 @@ public class Startup
globalSettings,
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(WebhookEventHandler.HttpClientName);
services.AddHttpClient(SlackService.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 =>
new RabbitMqEventListenerService(
provider.GetRequiredService<WebhookEventHandler>(),
@ -130,7 +147,6 @@ public class Startup
globalSettings.EventLogging.RabbitMq.WebhookQueueName));
}
}
}
public void Configure(
IApplicationBuilder app,

View File

@ -1,4 +1,5 @@
using System.Globalization;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@ -29,6 +30,12 @@ public class Startup
// Settings
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
// Data Protection
services.AddCustomDataProtectionServices(Environment, globalSettings);
// Repositories
services.AddDatabaseRepositories(globalSettings);
// Hosted Services
// Optional Azure Service Bus Listeners
@ -45,8 +52,25 @@ public class Startup
globalSettings,
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.AddHttpClient(WebhookEventHandler.HttpClientName);
@ -57,7 +81,6 @@ public class Startup
globalSettings,
globalSettings.EventLogging.AzureServiceBus.WebhookSubscriptionName));
}
}
services.AddHostedService<AzureQueueHostedService>();
}

View File

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

View File

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

View File

@ -12,9 +12,9 @@ using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using IdentityModel;
namespace Bit.Identity.IdentityServer;

View File

@ -1,6 +1,7 @@
using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Enums;
@ -39,6 +40,7 @@ public abstract class BaseRequestValidator<T> where T : class
protected ISsoConfigRepository SsoConfigRepository { get; }
protected IUserService _userService { get; }
protected IUserDecryptionOptionsBuilder UserDecryptionOptionsBuilder { get; }
protected IPolicyRequirementQuery PolicyRequirementQuery { get; }
public BaseRequestValidator(
UserManager<User> userManager,
@ -55,7 +57,8 @@ public abstract class BaseRequestValidator<T> where T : class
IPolicyService policyService,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder)
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery)
{
_userManager = userManager;
_userService = userService;
@ -72,6 +75,7 @@ public abstract class BaseRequestValidator<T> where T : class
FeatureService = featureService;
SsoConfigRepository = ssoConfigRepository;
UserDecryptionOptionsBuilder = userDecryptionOptionsBuilder;
PolicyRequirementQuery = policyRequirementQuery;
}
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
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);
if (anySsoPoliciesApplicableToUser)
if (ssoRequired)
{
return true;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,9 +14,23 @@ public class OrganizationUserReadOccupiedSeatCountByOrganizationIdQuery : IQuery
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
select ou;
return query;
select new OrganizationUser { Id = ou.Id, OrganizationId = ou.OrganizationId, Status = ou.Status };
// 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

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

View File

@ -24,6 +24,18 @@ public class SecurityTaskEntityTypeConfiguration : IEntityTypeConfiguration<Secu
.HasIndex(s => s.CipherId)
.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
.ToTable(nameof(SecurityTask));
}

View File

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

View File

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

View File

@ -50,7 +50,7 @@ using Bit.Core.Vault.Services;
using Bit.Infrastructure.Dapper;
using Bit.Infrastructure.EntityFramework;
using DnsClient;
using Duende.IdentityModel;
using IdentityModel;
using LaunchDarkly.Sdk.Server;
using LaunchDarkly.Sdk.Server.Interfaces;
using Microsoft.AspNetCore.Authentication.Cookies;

View File

@ -14,7 +14,7 @@ CREATE TABLE [dbo].[Notification]
CONSTRAINT [PK_Notification] PRIMARY KEY CLUSTERED ([Id] ASC),
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_SecurityTask] FOREIGN KEY ([TaskId]) REFERENCES [dbo].[SecurityTask] ([Id])
CONSTRAINT [FK_Notification_SecurityTask] FOREIGN KEY ([TaskId]) REFERENCES [dbo].[SecurityTask] ([Id]) ON DELETE CASCADE
);

View File

@ -14,24 +14,27 @@ BEGIN
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[Group]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionGroup] AS [Target]
USING
@Groups AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[GroupId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT -- Add explicit column list
-- Delete groups that are no longer in source
DELETE cg
FROM [dbo].[CollectionGroup] cg
LEFT JOIN @Groups g ON cg.GroupId = g.Id
WHERE cg.CollectionId = @Id
AND g.Id IS NULL;
-- Update existing groups
UPDATE cg
SET cg.ReadOnly = g.ReadOnly,
cg.HidePasswords = g.HidePasswords,
cg.Manage = g.Manage
FROM [dbo].[CollectionGroup] cg
INNER JOIN @Groups g ON cg.GroupId = g.Id
WHERE cg.CollectionId = @Id
AND (cg.ReadOnly != g.ReadOnly
OR cg.HidePasswords != g.HidePasswords
OR cg.Manage != g.Manage);
-- Insert new groups
INSERT INTO [dbo].[CollectionGroup]
(
[CollectionId],
[GroupId],
@ -39,46 +42,41 @@ BEGIN
[HidePasswords],
[Manage]
)
VALUES
(
SELECT
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
g.Id,
g.ReadOnly,
g.HidePasswords,
g.Manage
FROM @Groups g
INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id
LEFT JOIN [dbo].[CollectionGroup] cg
ON cg.CollectionId = @Id AND cg.GroupId = g.Id
WHERE grp.OrganizationId = @OrganizationId
AND cg.CollectionId IS NULL;
-- Users
;WITH [AvailableGroupsCTE] AS(
SELECT
Id
FROM
[dbo].[OrganizationUser]
WHERE
OrganizationId = @OrganizationId
)
MERGE
[dbo].[CollectionUser] AS [Target]
USING
@Users AS [Source]
ON
[Target].[CollectionId] = @Id
AND [Target].[OrganizationUserId] = [Source].[Id]
WHEN NOT MATCHED BY TARGET
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN
INSERT
-- Delete users that are no longer in source
DELETE cu
FROM [dbo].[CollectionUser] cu
LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id
WHERE cu.CollectionId = @Id
AND u.Id IS NULL;
-- Update existing users
UPDATE cu
SET cu.ReadOnly = u.ReadOnly,
cu.HidePasswords = u.HidePasswords,
cu.Manage = u.Manage
FROM [dbo].[CollectionUser] cu
INNER JOIN @Users u ON cu.OrganizationUserId = u.Id
WHERE cu.CollectionId = @Id
AND (cu.ReadOnly != u.ReadOnly
OR cu.HidePasswords != u.HidePasswords
OR cu.Manage != u.Manage);
-- Insert new users
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
@ -86,26 +84,18 @@ BEGIN
[HidePasswords],
[Manage]
)
VALUES
(
SELECT
@Id,
[Source].[Id],
[Source].[ReadOnly],
[Source].[HidePasswords],
[Source].[Manage]
)
WHEN MATCHED AND (
[Target].[ReadOnly] != [Source].[ReadOnly]
OR [Target].[HidePasswords] != [Source].[HidePasswords]
OR [Target].[Manage] != [Source].[Manage]
) THEN
UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly],
[Target].[HidePasswords] = [Source].[HidePasswords],
[Target].[Manage] = [Source].[Manage]
WHEN NOT MATCHED BY SOURCE
AND [Target].[CollectionId] = @Id THEN
DELETE
;
u.Id,
u.ReadOnly,
u.HidePasswords,
u.Manage
FROM @Users u
INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id
LEFT JOIN [dbo].[CollectionUser] cu
ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id
WHERE ou.OrganizationId = @OrganizationId
AND cu.CollectionId IS NULL;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END

View File

@ -5,10 +5,19 @@ BEGIN
SET NOCOUNT ON
SELECT
COUNT(1)
FROM
[dbo].[OrganizationUserView]
WHERE
OrganizationId = @OrganizationId
(
-- Count organization users
SELECT COUNT(1)
FROM [dbo].[OrganizationUserView]
WHERE OrganizationId = @OrganizationId
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

View File

@ -9,3 +9,9 @@
CONSTRAINT [FK_CollectionGroup_Group] FOREIGN KEY ([GroupId]) REFERENCES [dbo].[Group] ([Id]) ON DELETE CASCADE
);
GO
CREATE NONCLUSTERED INDEX IX_CollectionGroup_GroupId
ON [dbo].[CollectionGroup] (GroupId)
INCLUDE (ReadOnly, HidePasswords, Manage)
GO

View File

@ -9,3 +9,9 @@
CONSTRAINT [FK_CollectionUser_OrganizationUser] FOREIGN KEY ([OrganizationUserId]) REFERENCES [dbo].[OrganizationUser] ([Id])
);
GO
CREATE NONCLUSTERED INDEX IX_CollectionUser_OrganizationUserId
ON [dbo].[CollectionUser] (OrganizationUserId)
INCLUDE (ReadOnly, HidePasswords, Manage)
GO

View File

@ -6,7 +6,7 @@ CREATE TABLE [dbo].[PasswordHealthReportApplication]
CreationDate DATETIME2(7) NOT NULL,
RevisionDate DATETIME2(7) NOT NULL,
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

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,
Name = "chrome",
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"),
authDateTimeResponse)
@ -78,6 +80,13 @@ public class DevicesControllerTest
// Assert
Assert.NotNull(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]

View File

@ -1,7 +1,9 @@
using Bit.Api.Auth.Controllers;
using Bit.Api.Auth.Models.Request.Accounts;
using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Entities;
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);
}
[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
[Theory, BitAutoData]
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);
}
[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]
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);
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));

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.Http.Json;
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;
@ -8,7 +12,6 @@ using Bit.Test.Common.Helpers;
using Bit.Test.Common.MockedHttpClient;
using NSubstitute;
using Xunit;
using GlobalSettings = Bit.Core.Settings.GlobalSettings;
namespace Bit.Core.Test.Services;
@ -16,9 +19,18 @@ namespace Bit.Core.Test.Services;
public class WebhookEventHandlerTests
{
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 _webhookUrl2 = "http://localhost/another/event";
public WebhookEventHandlerTests()
{
@ -29,57 +41,195 @@ public class WebhookEventHandlerTests
_httpClient = _handler.ToHttpClient();
}
public SutProvider<WebhookEventHandler> GetSutProvider()
private SutProvider<WebhookEventHandler> GetSutProvider(
List<OrganizationIntegrationConfigurationDetails> configurations)
{
var clientFactory = Substitute.For<IHttpClientFactory>();
clientFactory.CreateClient(WebhookEventHandler.HttpClientName).Returns(_httpClient);
var globalSettings = new GlobalSettings();
globalSettings.EventLogging.WebhookUrl = _webhookUrl;
var repository = Substitute.For<IOrganizationIntegrationConfigurationRepository>();
repository.GetConfigurationDetailsAsync(Arg.Any<Guid>(),
IntegrationType.Webhook, Arg.Any<EventType>()).Returns(configurations);
return new SutProvider<WebhookEventHandler>()
.SetDependency(globalSettings)
.SetDependency(repository)
.SetDependency(clientFactory)
.Create();
}
[Theory, BitAutoData]
public async Task HandleEventAsync_PostsEventToUrl(EventMessage eventMessage)
private static List<OrganizationIntegrationConfigurationDetails> NoConfigurations()
{
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);
sutProvider.GetDependency<IHttpClientFactory>().Received(1).CreateClient(
Arg.Is(AssertHelper.AssertPropertyEqual<string>(WebhookEventHandler.HttpClientName))
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
);
Assert.Single(_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" });
Assert.Empty(_handler.CapturedRequests);
}
[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(
Arg.Is(AssertHelper.AssertPropertyEqual<string>(WebhookEventHandler.HttpClientName))
Arg.Is(AssertHelper.AssertPropertyEqual(WebhookEventHandler.HttpClientName))
);
Assert.Single(_handler.CapturedRequests);
var request = _handler.CapturedRequests[0];
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(_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

@ -41,7 +41,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).ReturnsNull();
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);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -55,7 +55,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user);
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);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -72,7 +72,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user);
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);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -91,7 +91,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<IUserService>().GetUserByIdAsync(orgUser.UserId!.Value).Returns(user);
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);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -115,7 +115,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
sutProvider.GetDependency<ICurrentContext>().UserId.Returns(orgUser.UserId.Value);
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);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().DidNotReceiveWithAnyArgs()
@ -147,7 +147,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
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);
}
@ -170,7 +170,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null);
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null);
var expectedSponsorship = new OrganizationSponsorship
{
@ -209,7 +209,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actualException = await Assert.ThrowsAsync<Exception>(() =>
sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, null));
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, false, null));
Assert.Same(expectedException, actualException);
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
@ -244,9 +244,9 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actual = await Assert.ThrowsAsync<UnauthorizedAccessException>(async () =>
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]
@ -278,9 +278,9 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
var actual = await Assert.ThrowsAsync<UnauthorizedAccessException>(async () =>
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]
@ -312,7 +312,7 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
]);
var actual = await sutProvider.Sut.CreateSponsorshipAsync(sponsoringOrg, sponsoringOrgUser,
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, notes);
PlanSponsorshipType.FamiliesForEnterprise, sponsoredEmail, friendlyName, true, notes);
var expectedSponsorship = new OrganizationSponsorship
@ -330,6 +330,6 @@ public class CreateSponsorshipCommandTests : FamiliesForEnterpriseTestsBase
Assert.True(SponsorshipValidator(expectedSponsorship, actual));
await sutProvider.GetDependency<IOrganizationSponsorshipRepository>().Received(1)
.UpsertAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
.CreateAsync(Arg.Is<OrganizationSponsorship>(s => SponsorshipValidator(s, expectedSponsorship)));
}
}

View File

@ -9,7 +9,7 @@ using Bit.Core.Test.AutoFixture.UserFixtures;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityModel;
using IdentityModel;
using Microsoft.AspNetCore.DataProtection;
using Xunit;

View File

@ -17,9 +17,9 @@ using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.Helpers;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;

View File

@ -17,9 +17,9 @@ using Bit.Core.Utilities;
using Bit.IntegrationTestCommon.Factories;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
using Duende.IdentityModel;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using IdentityModel;
using LinqToDB;
using Microsoft.Extensions.Caching.Distributed;
using NSubstitute;

View File

@ -1,6 +1,7 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
@ -41,6 +42,7 @@ public class BaseRequestValidatorTests
private readonly IFeatureService _featureService;
private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly BaseRequestValidatorTestWrapper _sut;
@ -61,6 +63,7 @@ public class BaseRequestValidatorTests
_featureService = Substitute.For<IFeatureService>();
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
_userDecryptionOptionsBuilder = Substitute.For<IUserDecryptionOptionsBuilder>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_sut = new BaseRequestValidatorTestWrapper(
_userManager,
@ -77,7 +80,8 @@ public class BaseRequestValidatorTests
_policyService,
_featureService,
_ssoConfigRepository,
_userDecryptionOptionsBuilder);
_userDecryptionOptionsBuilder,
_policyRequirementQuery);
}
/* Logic path
@ -276,6 +280,75 @@ public class BaseRequestValidatorTests
Assert.Equal("SSO authentication is required.", errorResponse.Message);
}
// Test grantTypes with RequireSsoPolicyRequirement when feature flag is enabled
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredTrue_ShouldSetSsoResult(
string grantType,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var context = CreateContext(tokenRequest, requestContext, grantResult);
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
// Configure requirement to require SSO
var requirement = new RequireSsoPolicyRequirement { SsoRequired = true };
_policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>()).Returns(requirement);
// Act
await _sut.ValidateAsync(context);
// Assert
await _policyService.DidNotReceive().AnyPoliciesApplicableToUserAsync(
Arg.Any<Guid>(), PolicyType.RequireSso, OrganizationUserStatusType.Confirmed);
Assert.True(context.GrantResult.IsError);
var errorResponse = (ErrorResponseModel)context.GrantResult.CustomResponse["ErrorModel"];
Assert.Equal("SSO authentication is required.", errorResponse.Message);
}
[Theory]
[BitAutoData("password")]
[BitAutoData("webauthn")]
[BitAutoData("refresh_token")]
public async Task ValidateAsync_GrantTypes_WithPolicyRequirementsEnabled_OrgSsoRequiredFalse_ShouldSucceed(
string grantType,
[AuthFixtures.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
CustomValidatorRequestContext requestContext,
GrantValidationResult grantResult)
{
// Arrange
_featureService.IsEnabled(FeatureFlagKeys.PolicyRequirements).Returns(true);
var context = CreateContext(tokenRequest, requestContext, grantResult);
context.CustomValidatorRequestContext.CaptchaResponse.IsBot = false;
_sut.isValid = true;
context.ValidatedTokenRequest.GrantType = grantType;
context.ValidatedTokenRequest.ClientId = "web";
// Configure requirement to not require SSO
var requirement = new RequireSsoPolicyRequirement { SsoRequired = false };
_policyRequirementQuery.GetAsync<RequireSsoPolicyRequirement>(Arg.Any<Guid>()).Returns(requirement);
_twoFactorAuthenticationValidator.RequiresTwoFactorAsync(requestContext.User, tokenRequest)
.Returns(Task.FromResult(new Tuple<bool, Organization>(false, null)));
_deviceValidator.ValidateRequestDeviceAsync(tokenRequest, requestContext)
.Returns(Task.FromResult(true));
await _sut.ValidateAsync(context);
Assert.False(context.GrantResult.IsError);
await _eventService.Received(1).LogUserEventAsync(
context.CustomValidatorRequestContext.User.Id, EventType.User_LoggedIn);
await _userRepository.Received(1).ReplaceAsync(Arg.Any<User>());
}
// Test grantTypes where SSO would be required but the user is not in an
// organization that requires it
[Theory]

View File

@ -1,4 +1,5 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Repositories;
using Bit.Core.Context;
@ -61,7 +62,8 @@ IBaseRequestValidatorTestWrapper
IPolicyService policyService,
IFeatureService featureService,
ISsoConfigRepository ssoConfigRepository,
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder) :
IUserDecryptionOptionsBuilder userDecryptionOptionsBuilder,
IPolicyRequirementQuery policyRequirementQuery) :
base(
userManager,
userService,
@ -77,7 +79,8 @@ IBaseRequestValidatorTestWrapper
policyService,
featureService,
ssoConfigRepository,
userDecryptionOptionsBuilder)
userDecryptionOptionsBuilder,
policyRequirementQuery)
{
}

View File

@ -1,6 +1,7 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
@ -912,4 +913,59 @@ public class CipherRepositoryTests
Assert.Equal(CipherType.SecureNote, updatedCipher1.Type);
Assert.Equal("new_attachments", updatedCipher2.Attachments);
}
[DatabaseTheory, DatabaseData]
public async Task DeleteCipherWithSecurityTaskAsync_Works(
IOrganizationRepository organizationRepository,
ICipherRepository cipherRepository,
ISecurityTaskRepository securityTaskRepository)
{
var organization = await organizationRepository.CreateAsync(new Organization
{
Name = "Test Org",
PlanType = PlanType.EnterpriseAnnually,
Plan = "Test Plan",
BillingEmail = ""
});
var cipher1 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", };
await cipherRepository.CreateAsync(cipher1);
var cipher2 = new Cipher { Type = CipherType.Login, OrganizationId = organization.Id, Data = "", };
await cipherRepository.CreateAsync(cipher2);
var tasks = new List<SecurityTask>
{
new()
{
OrganizationId = organization.Id,
CipherId = cipher1.Id,
Status = SecurityTaskStatus.Pending,
Type = SecurityTaskType.UpdateAtRiskCredential,
},
new()
{
OrganizationId = organization.Id,
CipherId = cipher2.Id,
Status = SecurityTaskStatus.Completed,
Type = SecurityTaskType.UpdateAtRiskCredential,
}
};
await securityTaskRepository.CreateManyAsync(tasks);
// Delete cipher with pending security task
await cipherRepository.DeleteAsync(cipher1);
var deletedCipher1 = await cipherRepository.GetByIdAsync(cipher1.Id);
Assert.Null(deletedCipher1);
// Delete cipher with completed security task
await cipherRepository.DeleteAsync(cipher2);
var deletedCipher2 = await cipherRepository.GetByIdAsync(cipher2.Id);
Assert.Null(deletedCipher2);
}
}

View File

@ -599,5 +599,11 @@ public class CollectionRepositoryTests
Assert.True(actualOrgUser3.Manage);
Assert.False(actualOrgUser3.HidePasswords);
Assert.True(actualOrgUser3.ReadOnly);
// Clean up data
await userRepository.DeleteAsync(user);
await organizationRepository.DeleteAsync(organization);
await groupRepository.DeleteManyAsync([group1.Id, group2.Id, group3.Id]);
await organizationUserRepository.DeleteManyAsync([orgUser1.Id, orgUser2.Id, orgUser3.Id]);
}
}

View File

@ -0,0 +1,11 @@
BEGIN
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Notification_SecurityTask')
BEGIN
ALTER TABLE [dbo].[Notification]
DROP CONSTRAINT [FK_Notification_SecurityTask]
END
ALTER TABLE [dbo].[Notification]
ADD CONSTRAINT [FK_Notification_SecurityTask] FOREIGN KEY ([TaskId]) REFERENCES [dbo].[SecurityTask] ([Id]) ON DELETE CASCADE
END
GO

View File

@ -0,0 +1,118 @@
CREATE OR ALTER PROCEDURE [dbo].[Collection_UpdateWithGroupsAndUsers]
@Id UNIQUEIDENTIFIER,
@OrganizationId UNIQUEIDENTIFIER,
@Name VARCHAR(MAX),
@ExternalId NVARCHAR(300),
@CreationDate DATETIME2(7),
@RevisionDate DATETIME2(7),
@Groups AS [dbo].[CollectionAccessSelectionType] READONLY,
@Users AS [dbo].[CollectionAccessSelectionType] READONLY
AS
BEGIN
SET NOCOUNT ON
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups
-- Delete groups that are no longer in source
DELETE cg
FROM [dbo].[CollectionGroup] cg
LEFT JOIN @Groups g ON cg.GroupId = g.Id
WHERE cg.CollectionId = @Id
AND g.Id IS NULL;
-- Update existing groups
UPDATE cg
SET cg.ReadOnly = g.ReadOnly,
cg.HidePasswords = g.HidePasswords,
cg.Manage = g.Manage
FROM [dbo].[CollectionGroup] cg
INNER JOIN @Groups g ON cg.GroupId = g.Id
WHERE cg.CollectionId = @Id
AND (cg.ReadOnly != g.ReadOnly
OR cg.HidePasswords != g.HidePasswords
OR cg.Manage != g.Manage);
-- Insert new groups
INSERT INTO [dbo].[CollectionGroup]
(
[CollectionId],
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@Id,
g.Id,
g.ReadOnly,
g.HidePasswords,
g.Manage
FROM @Groups g
INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id
LEFT JOIN [dbo].[CollectionGroup] cg
ON cg.CollectionId = @Id AND cg.GroupId = g.Id
WHERE grp.OrganizationId = @OrganizationId
AND cg.CollectionId IS NULL;
-- Users
-- Delete users that are no longer in source
DELETE cu
FROM [dbo].[CollectionUser] cu
LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id
WHERE cu.CollectionId = @Id
AND u.Id IS NULL;
-- Update existing users
UPDATE cu
SET cu.ReadOnly = u.ReadOnly,
cu.HidePasswords = u.HidePasswords,
cu.Manage = u.Manage
FROM [dbo].[CollectionUser] cu
INNER JOIN @Users u ON cu.OrganizationUserId = u.Id
WHERE cu.CollectionId = @Id
AND (cu.ReadOnly != u.ReadOnly
OR cu.HidePasswords != u.HidePasswords
OR cu.Manage != u.Manage);
-- Insert new users
INSERT INTO [dbo].[CollectionUser]
(
[CollectionId],
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
SELECT
@Id,
u.Id,
u.ReadOnly,
u.HidePasswords,
u.Manage
FROM @Users u
INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id
LEFT JOIN [dbo].[CollectionUser] cu
ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id
WHERE ou.OrganizationId = @OrganizationId
AND cu.CollectionId IS NULL;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END
GO
IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_CollectionGroup_GroupId')
BEGIN
CREATE NONCLUSTERED INDEX IX_CollectionGroup_GroupId
ON [dbo].[CollectionGroup] (GroupId)
INCLUDE (ReadOnly, HidePasswords, Manage)
END
GO
IF NOT EXISTS(SELECT name FROM sys.indexes WHERE name = 'IX_CollectionUser_OrganizationUserId')
BEGIN
CREATE NONCLUSTERED INDEX IX_CollectionUser_OrganizationUserId
ON [dbo].[CollectionUser] (OrganizationUserId)
INCLUDE (ReadOnly, HidePasswords, Manage)
END
GO

View File

@ -0,0 +1,11 @@
BEGIN
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_PasswordHealthReportApplication_Organization')
BEGIN
ALTER TABLE [dbo].[PasswordHealthReportApplication]
DROP CONSTRAINT [FK_PasswordHealthReportApplication_Organization]
END
ALTER TABLE [dbo].[PasswordHealthReportApplication]
ADD CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
END
GO

View File

@ -0,0 +1,32 @@
-- Update OrganizationUser_ReadOccupiedSeatCountByOrganizationId to include admin-initiated sponsorships
-- Based on https://bitwarden.atlassian.net/browse/PM-17772
IF OBJECT_ID('[dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]') IS NOT NULL
BEGIN
DROP PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]
END
GO
CREATE PROCEDURE [dbo].[OrganizationUser_ReadOccupiedSeatCountByOrganizationId]
@OrganizationId UNIQUEIDENTIFIER
AS
BEGIN
SET NOCOUNT ON
SELECT
(
-- Count organization users
SELECT COUNT(1)
FROM [dbo].[OrganizationUserView]
WHERE OrganizationId = @OrganizationId
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
GO

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