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", "nuget",
], ],
packageRules: [ packageRules: [
{
// Group all release-related workflows for GitHub Actions together for BRE.
groupName: "github-action",
matchManagers: ["github-actions"],
matchFileNames: [
".github/workflows/publish.yml",
".github/workflows/release.yml",
".github/workflows/repository-management.yml"
],
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
addLabels: ["hold"]
},
{ {
groupName: "dockerfile minor", groupName: "dockerfile minor",
matchManagers: ["dockerfile"], matchManagers: ["dockerfile"],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -0,0 +1,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 ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
var isTdeEnrollment = ssoConfig != null && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption; var isTdeEnrollment = ssoConfig != null && ssoConfig.Enabled && ssoConfig.GetData().MemberDecryptionType == MemberDecryptionType.TrustedDeviceEncryption;
if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash)) if (!isTdeEnrollment && !string.IsNullOrWhiteSpace(model.ResetPasswordKey) && !await _userService.VerifySecretAsync(user, model.MasterPasswordHash))
{ {
throw new BadRequestException("Incorrect password"); throw new BadRequestException("Incorrect password");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,57 @@

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

View File

@ -159,13 +159,13 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task RevertPasswordManagerChangesAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd > 0) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { Seats: > 0, SeatsRequiredToAdd: > 0 })
{ {
// When reverting seats, we have to tell payments service that the seats are going back down by what we attempted to add.
// However, this might lead to a problem if we don't actually update stripe but throw any ways.
// stripe could not be updated, and then we would decrement the number of seats in stripe accidentally. await paymentService.AdjustSeatsAsync(organization,
var seatsToRemove = validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd; validatedResult.Value.InviteOrganization.Plan,
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, -seatsToRemove); validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats; organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats;
@ -274,25 +274,25 @@ public class InviteOrganizationUsersCommand(IEventService eventService,
private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization) private async Task AdjustPasswordManagerSeatsAsync(Valid<InviteOrganizationUsersValidationRequest> validatedResult, Organization organization)
{ {
if (validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd <= 0) if (validatedResult.Value.PasswordManagerSubscriptionUpdate is { SeatsRequiredToAdd: > 0, UpdatedSeatTotal: > 0 })
{ {
return; await paymentService.AdjustSeatsAsync(organization,
validatedResult.Value.InviteOrganization.Plan,
validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal.Value);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
PreviousSeats = validatedResult.Value.PasswordManagerSubscriptionUpdate.Seats
});
} }
await paymentService.AdjustSeatsAsync(organization, validatedResult.Value.InviteOrganization.Plan, validatedResult.Value.PasswordManagerSubscriptionUpdate.SeatsRequiredToAdd);
organization.Seats = (short?)validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal;
await organizationRepository.ReplaceAsync(organization); // could optimize this with only a property update
await applicationCacheService.UpsertOrganizationAbilityAsync(organization);
await referenceEventService.RaiseEventAsync(
new ReferenceEvent(ReferenceEventType.AdjustSeats, organization, currentContext)
{
PlanName = validatedResult.Value.InviteOrganization.Plan.Name,
PlanType = validatedResult.Value.InviteOrganization.Plan.Type,
Seats = validatedResult.Value.PasswordManagerSubscriptionUpdate.UpdatedSeatTotal,
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>, SendOptionsPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, ResetPasswordPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>(); services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, PersonalOwnershipPolicyRequirementFactory>();
services.AddScoped<IPolicyRequirementFactory<IPolicyRequirement>, RequireSsoPolicyRequirementFactory>();
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,30 +1,57 @@
using System.Net.Http.Json; using System.Text;
using System.Text.Json;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Settings; using Bit.Core.Models.Data.Integrations;
using Bit.Core.Repositories;
#nullable enable
namespace Bit.Core.Services; namespace Bit.Core.Services;
public class WebhookEventHandler( public class WebhookEventHandler(
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
GlobalSettings globalSettings) IOrganizationIntegrationConfigurationRepository configurationRepository)
: IEventMessageHandler : IEventMessageHandler
{ {
private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName); private readonly HttpClient _httpClient = httpClientFactory.CreateClient(HttpClientName);
private readonly string _webhookUrl = globalSettings.EventLogging.WebhookUrl;
public const string HttpClientName = "WebhookEventHandlerHttpClient"; public const string HttpClientName = "WebhookEventHandlerHttpClient";
public async Task HandleEventAsync(EventMessage eventMessage) public async Task HandleEventAsync(EventMessage eventMessage)
{ {
var content = JsonContent.Create(eventMessage); var organizationId = eventMessage.OrganizationId ?? Guid.Empty;
var response = await _httpClient.PostAsync(_webhookUrl, content); var configurations = await configurationRepository.GetConfigurationDetailsAsync(
response.EnsureSuccessStatusCode(); 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) public async Task HandleManyEventsAsync(IEnumerable<EventMessage> eventMessages)
{ {
var content = JsonContent.Create(eventMessages); foreach (var eventMessage in eventMessages)
var response = await _httpClient.PostAsync(_webhookUrl, content); {
response.EnsureSuccessStatusCode(); await HandleEventAsync(eventMessage);
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -112,7 +112,6 @@ public static class FeatureFlagKeys
/* Auth Team */ /* Auth Team */
public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence"; public const string PM9112DeviceApprovalPersistence = "pm-9112-device-approval-persistence";
public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence"; public const string TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence";
public const string DuoRedirect = "duo-redirect";
public const string EmailVerification = "email-verification"; public const string EmailVerification = "email-verification";
public const string DeviceTrustLogging = "pm-8285-device-trust-logging"; public const string DeviceTrustLogging = "pm-8285-device-trust-logging";
public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token"; public const string AuthenticatorTwoFactorToken = "authenticator-2fa-token";
@ -176,6 +175,7 @@ public static class FeatureFlagKeys
public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias"; public const string PM3553_MobileSimpleLoginSelfHostAlias = "simple-login-self-host-alias";
public const string EnablePMFlightRecorder = "enable-pm-flight-recorder"; public const string EnablePMFlightRecorder = "enable-pm-flight-recorder";
public const string MobileErrorReporting = "mobile-error-reporting"; public const string MobileErrorReporting = "mobile-error-reporting";
public const string AndroidChromeAutofill = "android-chrome-autofill";
/* Platform Team */ /* Platform Team */
public const string PersistPopupView = "persist-popup-view"; 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 RiskInsightsCriticalApplication = "pm-14466-risk-insights-critical-application";
public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications"; public const string EnableRiskInsightsNotifications = "enable-risk-insights-notifications";
public const string DesktopSendUIRefresh = "desktop-send-ui-refresh"; public const string DesktopSendUIRefresh = "desktop-send-ui-refresh";
public const string ExportAttachments = "export-attachments";
/* Vault Team */ /* Vault Team */
public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge"; public const string PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge";
@ -216,9 +215,6 @@ public static class FeatureFlagKeys
public static Dictionary<string, string> GetLocalOverrideFlagValues() public static Dictionary<string, string> GetLocalOverrideFlagValues()
{ {
// place overriding values when needed locally (offline), or return null // place overriding values when needed locally (offline), or return null
return new Dictionary<string, string>() return null;
{
{ DuoRedirect, "true" },
};
} }
} }

View File

@ -52,7 +52,7 @@
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" /> <PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" /> <PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Sentry.Serilog" Version="5.0.0" /> <PackageReference Include="Sentry.Serilog" Version="5.0.0" />
<PackageReference Include="Duende.IdentityServer" Version="7.1.0" /> <PackageReference Include="Duende.IdentityServer" Version="7.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" /> <PackageReference Include="Serilog.Sinks.SyslogMessages" Version="4.0.0" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />

View File

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

View File

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

View File

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

View File

@ -53,6 +53,7 @@ public class GlobalSettings : IGlobalSettings
public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings(); public virtual SqlSettings PostgreSql { get; set; } = new SqlSettings();
public virtual SqlSettings MySql { get; set; } = new SqlSettings(); public virtual SqlSettings MySql { get; set; } = new SqlSettings();
public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" }; public virtual SqlSettings Sqlite { get; set; } = new SqlSettings() { ConnectionString = "Data Source=:memory:" };
public virtual SlackSettings Slack { get; set; } = new SlackSettings();
public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings(); public virtual EventLoggingSettings EventLogging { get; set; } = new EventLoggingSettings();
public virtual MailSettings Mail { get; set; } = new MailSettings(); public virtual MailSettings Mail { get; set; } = new MailSettings();
public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings(); public virtual IConnectionStringSettings Storage { get; set; } = new ConnectionStringSettings();
@ -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 class EventLoggingSettings
{ {
public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings(); public AzureServiceBusSettings AzureServiceBus { get; set; } = new AzureServiceBusSettings();
public virtual string WebhookUrl { get; set; }
public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings(); public RabbitMqSettings RabbitMq { get; set; } = new RabbitMqSettings();
public class AzureServiceBusSettings public class AzureServiceBusSettings
@ -284,6 +292,7 @@ public class GlobalSettings : IGlobalSettings
private string _topicName; private string _topicName;
public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription"; public virtual string EventRepositorySubscriptionName { get; set; } = "events-write-subscription";
public virtual string SlackSubscriptionName { get; set; } = "events-slack-subscription";
public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription"; public virtual string WebhookSubscriptionName { get; set; } = "events-webhook-subscription";
public string ConnectionString public string ConnectionString
@ -308,6 +317,7 @@ public class GlobalSettings : IGlobalSettings
public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue"; public virtual string EventRepositoryQueueName { get; set; } = "events-write-queue";
public virtual string WebhookQueueName { get; set; } = "events-webhook-queue"; public virtual string WebhookQueueName { get; set; } = "events-webhook-queue";
public virtual string SlackQueueName { get; set; } = "events-slack-queue";
public string HostName public string HostName
{ {

View File

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

View File

@ -1,12 +1,13 @@
using System.Globalization; using System.Globalization;
using Bit.Core.AdminConsole.Services.Implementations; using Bit.Core.AdminConsole.Services.Implementations;
using Bit.Core.AdminConsole.Services.NoopImplementations;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.IdentityServer; using Bit.Core.IdentityServer;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.SharedWeb.Utilities; using Bit.SharedWeb.Utilities;
using Duende.IdentityModel; using IdentityModel;
namespace Bit.Events; namespace Bit.Events;
@ -117,18 +118,33 @@ public class Startup
globalSettings, globalSettings,
globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName)); globalSettings.EventLogging.RabbitMq.EventRepositoryQueueName));
if (CoreHelpers.SettingHasValue(globalSettings.EventLogging.WebhookUrl)) if (CoreHelpers.SettingHasValue(globalSettings.Slack.ClientId) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.ClientSecret) &&
CoreHelpers.SettingHasValue(globalSettings.Slack.Scopes))
{ {
services.AddSingleton<WebhookEventHandler>(); services.AddHttpClient(SlackService.HttpClientName);
services.AddHttpClient(WebhookEventHandler.HttpClientName); services.AddSingleton<ISlackService, SlackService>();
services.AddSingleton<IHostedService>(provider =>
new RabbitMqEventListenerService(
provider.GetRequiredService<WebhookEventHandler>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.WebhookQueueName));
} }
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>(),
provider.GetRequiredService<ILogger<RabbitMqEventListenerService>>(),
globalSettings,
globalSettings.EventLogging.RabbitMq.WebhookQueueName));
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,98 +14,88 @@ BEGIN
EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate EXEC [dbo].[Collection_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate
-- Groups -- Groups
;WITH [AvailableGroupsCTE] AS( -- Delete groups that are no longer in source
SELECT DELETE cg
Id FROM [dbo].[CollectionGroup] cg
FROM LEFT JOIN @Groups g ON cg.GroupId = g.Id
[dbo].[Group] WHERE cg.CollectionId = @Id
WHERE AND g.Id IS NULL;
OrganizationId = @OrganizationId
-- 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]
) )
MERGE SELECT
[dbo].[CollectionGroup] AS [Target] @Id,
USING g.Id,
@Groups AS [Source] g.ReadOnly,
ON g.HidePasswords,
[Target].[CollectionId] = @Id g.Manage
AND [Target].[GroupId] = [Source].[Id] FROM @Groups g
WHEN NOT MATCHED BY TARGET INNER JOIN [dbo].[Group] grp ON grp.Id = g.Id
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN LEFT JOIN [dbo].[CollectionGroup] cg
INSERT -- Add explicit column list ON cg.CollectionId = @Id AND cg.GroupId = g.Id
( WHERE grp.OrganizationId = @OrganizationId
[CollectionId], AND cg.CollectionId IS NULL;
[GroupId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@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
;
-- Users -- Users
;WITH [AvailableGroupsCTE] AS( -- Delete users that are no longer in source
SELECT DELETE cu
Id FROM [dbo].[CollectionUser] cu
FROM LEFT JOIN @Users u ON cu.OrganizationUserId = u.Id
[dbo].[OrganizationUser] WHERE cu.CollectionId = @Id
WHERE AND u.Id IS NULL;
OrganizationId = @OrganizationId
-- 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]
) )
MERGE SELECT
[dbo].[CollectionUser] AS [Target] @Id,
USING u.Id,
@Users AS [Source] u.ReadOnly,
ON u.HidePasswords,
[Target].[CollectionId] = @Id u.Manage
AND [Target].[OrganizationUserId] = [Source].[Id] FROM @Users u
WHEN NOT MATCHED BY TARGET INNER JOIN [dbo].[OrganizationUser] ou ON ou.Id = u.Id
AND [Source].[Id] IN (SELECT [Id] FROM [AvailableGroupsCTE]) THEN LEFT JOIN [dbo].[CollectionUser] cu
INSERT ON cu.CollectionId = @Id AND cu.OrganizationUserId = u.Id
( WHERE ou.OrganizationId = @OrganizationId
[CollectionId], AND cu.CollectionId IS NULL;
[OrganizationUserId],
[ReadOnly],
[HidePasswords],
[Manage]
)
VALUES
(
@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
;
EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId EXEC [dbo].[User_BumpAccountRevisionDateByCollectionId] @Id, @OrganizationId
END END

View File

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

View File

@ -9,3 +9,9 @@
CONSTRAINT [FK_CollectionGroup_Group] FOREIGN KEY ([GroupId]) REFERENCES [dbo].[Group] ([Id]) ON DELETE CASCADE 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]) 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, CreationDate DATETIME2(7) NOT NULL,
RevisionDate DATETIME2(7) NOT NULL, RevisionDate DATETIME2(7) NOT NULL,
CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC), CONSTRAINT [PK_PasswordHealthReportApplication] PRIMARY KEY CLUSTERED ([Id] ASC),
CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]), CONSTRAINT [FK_PasswordHealthReportApplication_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE
); );
GO GO

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,7 @@
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.Services; using Bit.Core.AdminConsole.Services;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
@ -41,6 +42,7 @@ public class BaseRequestValidatorTests
private readonly IFeatureService _featureService; private readonly IFeatureService _featureService;
private readonly ISsoConfigRepository _ssoConfigRepository; private readonly ISsoConfigRepository _ssoConfigRepository;
private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder; private readonly IUserDecryptionOptionsBuilder _userDecryptionOptionsBuilder;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly BaseRequestValidatorTestWrapper _sut; private readonly BaseRequestValidatorTestWrapper _sut;
@ -61,6 +63,7 @@ public class BaseRequestValidatorTests
_featureService = Substitute.For<IFeatureService>(); _featureService = Substitute.For<IFeatureService>();
_ssoConfigRepository = Substitute.For<ISsoConfigRepository>(); _ssoConfigRepository = Substitute.For<ISsoConfigRepository>();
_userDecryptionOptionsBuilder = Substitute.For<IUserDecryptionOptionsBuilder>(); _userDecryptionOptionsBuilder = Substitute.For<IUserDecryptionOptionsBuilder>();
_policyRequirementQuery = Substitute.For<IPolicyRequirementQuery>();
_sut = new BaseRequestValidatorTestWrapper( _sut = new BaseRequestValidatorTestWrapper(
_userManager, _userManager,
@ -77,7 +80,8 @@ public class BaseRequestValidatorTests
_policyService, _policyService,
_featureService, _featureService,
_ssoConfigRepository, _ssoConfigRepository,
_userDecryptionOptionsBuilder); _userDecryptionOptionsBuilder,
_policyRequirementQuery);
} }
/* Logic path /* Logic path
@ -276,6 +280,75 @@ public class BaseRequestValidatorTests
Assert.Equal("SSO authentication is required.", errorResponse.Message); 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 // Test grantTypes where SSO would be required but the user is not in an
// organization that requires it // organization that requires it
[Theory] [Theory]

View File

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

View File

@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
@ -912,4 +913,59 @@ public class CipherRepositoryTests
Assert.Equal(CipherType.SecureNote, updatedCipher1.Type); Assert.Equal(CipherType.SecureNote, updatedCipher1.Type);
Assert.Equal("new_attachments", updatedCipher2.Attachments); 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.True(actualOrgUser3.Manage);
Assert.False(actualOrgUser3.HidePasswords); Assert.False(actualOrgUser3.HidePasswords);
Assert.True(actualOrgUser3.ReadOnly); 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