Merge branch 'main' into km/pm-25652

This commit is contained in:
Thomas Avery 2025-12-08 10:37:45 -06:00 committed by GitHub
commit bc70a69fec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
292 changed files with 26656 additions and 2746 deletions

View File

@ -1,4 +1,4 @@
name: Bitwarden Lite Deployment Bug Report
name: Bitwarden lite Deployment Bug Report
description: File a bug report
labels: [bug, bw-lite-deploy]
body:
@ -74,7 +74,7 @@ body:
id: epic-label
attributes:
label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Lite
description: Link to our pinned issue, tracking all Bitwarden lite
value: |
https://github.com/bitwarden/server/issues/2480
validations:

View File

@ -44,6 +44,7 @@
{
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
dependencyDashboardApproval: true
},
{
matchManagers: ["dockerfile", "docker-compose"],
@ -63,7 +64,6 @@
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
@ -137,6 +137,7 @@
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",

View File

@ -185,13 +185,6 @@ jobs:
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
@ -250,8 +243,6 @@ jobs:
linux/arm64
push: true
tags: ${{ steps.image-tags.outputs.tags }}
secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@ -280,7 +271,7 @@ jobs:
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@ -479,20 +470,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger Bitwarden Lite build
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: self-host
- name: Trigger Bitwarden lite build
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
@ -520,20 +520,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: devops
- name: Trigger k8s deploy
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',

View File

@ -62,7 +62,7 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
- name: Add MariaDB for Bitwarden Lite
- name: Add MariaDB for Bitwarden lite
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
@ -133,7 +133,7 @@ jobs:
# Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Bitwarden Lite MariaDB
# Bitwarden lite MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
@ -262,3 +262,26 @@ jobs:
working-directory: "dev"
run: docker compose down
shell: pwsh
validate-migration-naming:
name: Validate new migration naming and order
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Validate new migrations for pull request
if: github.event_name == 'pull_request'
run: |
git fetch origin main:main
pwsh dev/verify_migrations.ps1 -BaseRef main
shell: pwsh
- name: Validate new migrations for push
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
shell: pwsh

View File

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

View File

@ -0,0 +1,94 @@
using AutoMapper;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class SecretVersionRepository : Repository<Core.SecretsManager.Entities.SecretVersion, SecretVersion, Guid>, ISecretVersionRepository
{
public SecretVersionRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, db => db.SecretVersion)
{ }
public override async Task<Core.SecretsManager.Entities.SecretVersion?> GetByIdAsync(Guid id)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var secretVersion = await dbContext.SecretVersion
.Where(sv => sv.Id == id)
.FirstOrDefaultAsync();
return Mapper.Map<Core.SecretsManager.Entities.SecretVersion>(secretVersion);
}
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyBySecretIdAsync(Guid secretId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var secretVersions = await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretId)
.OrderByDescending(sv => sv.VersionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
}
public async Task<IEnumerable<Core.SecretsManager.Entities.SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var versionIds = ids.ToList();
var secretVersions = await dbContext.SecretVersion
.Where(sv => versionIds.Contains(sv.Id))
.OrderByDescending(sv => sv.VersionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.SecretVersion>>(secretVersions);
}
public override async Task<Core.SecretsManager.Entities.SecretVersion> CreateAsync(Core.SecretsManager.Entities.SecretVersion secretVersion)
{
const int maxVersionsToKeep = 10;
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
await using var transaction = await dbContext.Database.BeginTransactionAsync();
// Get the IDs of the most recent (maxVersionsToKeep - 1) versions to keep
var versionsToKeepIds = await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretVersion.SecretId)
.OrderByDescending(sv => sv.VersionDate)
.Take(maxVersionsToKeep - 1)
.Select(sv => sv.Id)
.ToListAsync();
// Delete all versions for this secret that are not in the "keep" list
if (versionsToKeepIds.Any())
{
await dbContext.SecretVersion
.Where(sv => sv.SecretId == secretVersion.SecretId && !versionsToKeepIds.Contains(sv.Id))
.ExecuteDeleteAsync();
}
secretVersion.SetNewId();
var entity = Mapper.Map<SecretVersion>(secretVersion);
await dbContext.AddAsync(entity);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return secretVersion;
}
public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var secretVersionIds = ids.ToList();
await dbContext.SecretVersion
.Where(sv => secretVersionIds.Contains(sv.Id))
.ExecuteDeleteAsync();
}
}

View File

@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions
{
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
services.AddSingleton<ISecretRepository, SecretRepository>();
services.AddSingleton<ISecretVersionRepository, SecretVersionRepository>();
services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
}

View File

@ -61,17 +61,15 @@ public class GroupsController : Controller
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
[FromQuery] GetGroupsQueryParamModel model)
{
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex);
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model);
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
{
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()),
ItemsPerPage = model.Count,
TotalResults = groupsListQueryResult.totalResults,
StartIndex = startIndex.GetValueOrDefault(1),
StartIndex = model.StartIndex,
};
return Ok(scimListResponseModel);
}

View File

@ -3,6 +3,7 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@ -4,6 +4,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
namespace Bit.Scim.Groups;
@ -16,10 +17,16 @@ public class GetGroupsListQuery : IGetGroupsListQuery
_groupRepository = groupRepository;
}
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex)
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(
Guid organizationId, GetGroupsQueryParamModel groupQueryParams)
{
string nameFilter = null;
string externalIdFilter = null;
int count = groupQueryParams.Count;
int startIndex = groupQueryParams.StartIndex;
string filter = groupQueryParams.Filter;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("displayName eq "))
@ -53,11 +60,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery
}
totalResults = groupList.Count;
}
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
else if (string.IsNullOrWhiteSpace(filter))
{
groupList = groups.OrderBy(g => g.Name)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.Skip(startIndex - 1)
.Take(count)
.ToList();
totalResults = groups.Count;
}

View File

@ -1,8 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IGetGroupsListQuery
{
Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex);
Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, GetGroupsQueryParamModel model);
}

View File

@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Scim.Models;
public class GetGroupsQueryParamModel
{
public string Filter { get; init; } = string.Empty;
[Range(1, int.MaxValue)]
public int Count { get; init; } = 50;
[Range(1, int.MaxValue)]
public int StartIndex { get; init; } = 1;
}

View File

@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Scim.Models;
public class GetUsersQueryParamModel
{
public string Filter { get; init; } = string.Empty;

View File

@ -3,6 +3,7 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
namespace Bit.Scim.Users;

View File

@ -1,4 +1,5 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;

View File

@ -1,5 +1,5 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;

View File

@ -25,6 +25,12 @@
"connectionString": "UseDevelopmentStorage=true"
},
"developmentDirectory": "../../../dev",
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
"pricingUri": "https://billingpricing.qa.bitwarden.pw",
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
}
}
}

View File

@ -13,7 +13,11 @@
"mail": {
"sendGridApiKey": "SECRET",
"amazonConfigSetName": "Email",
"replyToEmail": "no-reply@bitwarden.com"
"replyToEmail": "no-reply@bitwarden.com",
"smtp": {
"host": "localhost",
"port": 10250
}
},
"identityServer": {
"certificateThumbprint": "SECRET"

View File

@ -0,0 +1,130 @@
using Bit.Core.SecretsManager.Entities;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretsManager.Repositories;
public class SecretVersionRepositoryTests
{
[Theory]
[BitAutoData]
public void SecretVersion_EntityCreation_Success(SecretVersion secretVersion)
{
// Arrange & Act
secretVersion.SetNewId();
// Assert
Assert.NotEqual(Guid.Empty, secretVersion.Id);
Assert.NotEqual(Guid.Empty, secretVersion.SecretId);
Assert.NotNull(secretVersion.Value);
Assert.NotEqual(default, secretVersion.VersionDate);
}
[Theory]
[BitAutoData]
public void SecretVersion_WithServiceAccountEditor_Success(SecretVersion secretVersion, Guid serviceAccountId)
{
// Arrange & Act
secretVersion.EditorServiceAccountId = serviceAccountId;
secretVersion.EditorOrganizationUserId = null;
// Assert
Assert.Equal(serviceAccountId, secretVersion.EditorServiceAccountId);
Assert.Null(secretVersion.EditorOrganizationUserId);
}
[Theory]
[BitAutoData]
public void SecretVersion_WithOrganizationUserEditor_Success(SecretVersion secretVersion, Guid organizationUserId)
{
// Arrange & Act
secretVersion.EditorOrganizationUserId = organizationUserId;
secretVersion.EditorServiceAccountId = null;
// Assert
Assert.Equal(organizationUserId, secretVersion.EditorOrganizationUserId);
Assert.Null(secretVersion.EditorServiceAccountId);
}
[Theory]
[BitAutoData]
public void SecretVersion_NullableEditors_Success(SecretVersion secretVersion)
{
// Arrange & Act
secretVersion.EditorServiceAccountId = null;
secretVersion.EditorOrganizationUserId = null;
// Assert
Assert.Null(secretVersion.EditorServiceAccountId);
Assert.Null(secretVersion.EditorOrganizationUserId);
}
[Theory]
[BitAutoData]
public void SecretVersion_VersionDateSet_Success(SecretVersion secretVersion)
{
// Arrange
var versionDate = DateTime.UtcNow;
// Act
secretVersion.VersionDate = versionDate;
// Assert
Assert.Equal(versionDate, secretVersion.VersionDate);
}
[Theory]
[BitAutoData]
public void SecretVersion_ValueEncrypted_Success(SecretVersion secretVersion, string encryptedValue)
{
// Arrange & Act
secretVersion.Value = encryptedValue;
// Assert
Assert.Equal(encryptedValue, secretVersion.Value);
Assert.NotEmpty(secretVersion.Value);
}
[Theory]
[BitAutoData]
public void SecretVersion_MultipleVersions_DifferentIds(List<SecretVersion> secretVersions, Guid secretId)
{
// Arrange & Act
foreach (var version in secretVersions)
{
version.SecretId = secretId;
version.SetNewId();
}
// Assert
var distinctIds = secretVersions.Select(v => v.Id).Distinct();
Assert.Equal(secretVersions.Count, distinctIds.Count());
Assert.All(secretVersions, v => Assert.Equal(secretId, v.SecretId));
}
[Theory]
[BitAutoData]
public void SecretVersion_VersionDateOrdering_Success(SecretVersion version1, SecretVersion version2, SecretVersion version3, Guid secretId)
{
// Arrange
var now = DateTime.UtcNow;
version1.SecretId = secretId;
version1.VersionDate = now.AddDays(-2);
version2.SecretId = secretId;
version2.VersionDate = now.AddDays(-1);
version3.SecretId = secretId;
version3.VersionDate = now;
var versions = new List<SecretVersion> { version2, version3, version1 };
// Act
var orderedVersions = versions.OrderByDescending(v => v.VersionDate).ToList();
// Assert
Assert.Equal(version3.Id, orderedVersions[0].Id); // Most recent
Assert.Equal(version2.Id, orderedVersions[1].Id);
Assert.Equal(version1.Id, orderedVersions[2].Id); // Oldest
}
}

View File

@ -200,6 +200,38 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
[Fact]
public async Task GetList_SearchDisplayNameWithoutOptionalParameters_Success()
{
string filter = "displayName eq Test Group 2";
int? itemsPerPage = null;
int? startIndex = null;
var expectedResponse = new ScimListResponseModel<ScimGroupResponseModel>
{
ItemsPerPage = 50, //default value
TotalResults = 1,
StartIndex = 1, //default value
Resources = new List<ScimGroupResponseModel>
{
new ScimGroupResponseModel
{
Id = ScimApplicationFactory.TestGroupId2,
DisplayName = "Test Group 2",
ExternalId = "B",
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup }
}
},
Schemas = new List<string> { ScimConstants.Scim2SchemaListResponse }
};
var context = await _factory.GroupsGetListAsync(ScimApplicationFactory.TestOrganizationId1, filter, itemsPerPage, startIndex);
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
var responseModel = JsonSerializer.Deserialize<ScimListResponseModel<ScimGroupResponseModel>>(context.Response.Body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
}
[Fact]
public async Task Post_Success()
{

View File

@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Scim.Groups;
using Bit.Scim.Models;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Bit.Test.Common.Helpers;
@ -24,7 +25,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, null, count, startIndex);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Count = count, StartIndex = startIndex });
AssertHelper.AssertPropertyEqual(groups.Skip(startIndex - 1).Take(count).ToList(), result.groupList);
AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults);
@ -47,7 +48,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
@ -67,7 +68,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
@ -90,7 +91,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
@ -112,7 +113,7 @@ public class GetGroupsListCommandTests
.GetManyByOrganizationIdAsync(organizationId)
.Returns(groups);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, filter, null, null);
var result = await sutProvider.Sut.GetGroupsListAsync(organizationId, new GetGroupsQueryParamModel { Filter = filter });
AssertHelper.AssertPropertyEqual(expectedGroupList, result.groupList);
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);

View File

@ -1,5 +1,6 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Models;
using Bit.Scim.Users;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;

View File

@ -1,6 +1,6 @@
using System.Text.Json;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;

View File

@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) {
# Api internal & public
Set-Location "../../src/Api"
dotnet build
dotnet swagger tofile --output "../../api.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "internal"
dotnet swagger tofile --output "../../api.json" "./bin/Debug/net8.0/Api.dll" "internal"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
dotnet swagger tofile --output "../../api.public.json" --host "https://api.bitwarden.com" "./bin/Debug/net8.0/Api.dll" "public"
dotnet swagger tofile --output "../../api.public.json" "./bin/Debug/net8.0/Api.dll" "public"
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}

132
dev/verify_migrations.ps1 Normal file
View File

@ -0,0 +1,132 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
This script validates migration files in util/Migrator/DbScripts/ to ensure:
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
2. New migrations are chronologically ordered (filename sorts after existing migrations)
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
4. A 2-digit sequence number is included (e.g., _00, _01)
.PARAMETER BaseRef
The base git reference to compare against (e.g., 'main', 'HEAD~1')
.PARAMETER CurrentRef
The current git reference (defaults to 'HEAD')
.EXAMPLE
# For pull requests - compare against main branch
.\verify_migrations.ps1 -BaseRef main
.EXAMPLE
# For pushes - compare against previous commit
.\verify_migrations.ps1 -BaseRef HEAD~1
#>
param(
[Parameter(Mandatory = $true)]
[string]$BaseRef,
[Parameter(Mandatory = $false)]
[string]$CurrentRef = "HEAD"
)
# Use invariant culture for consistent string comparison
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
$migrationPath = "util/Migrator/DbScripts"
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
}
catch {
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
if ($addedMigrations.Count -eq 0) {
Write-Host "No new migration files added."
exit 0
}
Write-Host "New migration files detected:"
$addedMigrations | ForEach-Object { Write-Host " $_" }
Write-Host ""
# Get the last migration from base reference
if ($baseMigrations.Count -eq 0) {
Write-Host "No previous migrations found (initial commit?). Skipping validation."
exit 0
}
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last migration in base reference: $lastBaseMigration"
Write-Host ""
# Required format regex: YYYY-MM-DD_NN_Description.sql
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
$validationFailed = $false
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Validate NEW migration filename format
if ($migrationName -notmatch $formatRegex) {
Write-Host "ERROR: Migration '$migrationName' does not match required format"
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
Write-Host " - YYYY: 4-digit year"
Write-Host " - MM: 2-digit month with leading zero (01-12)"
Write-Host " - DD: 2-digit day with leading zero (01-31)"
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
$validationFailed = $true
continue
}
# Compare migration name with last base migration (using ordinal string comparison)
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
$validationFailed = $true
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
}
}
Write-Host ""
if ($validationFailed) {
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new migration files must:"
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
Write-Host " 4. Have a filename that sorts after the last migration in base"
Write-Host ""
Write-Host "To fix this issue:"
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
Write-Host " 3. Ensure the date is after $lastBaseMigration"
Write-Host ""
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
exit 1
}
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
exit 0

View File

@ -473,6 +473,7 @@ public class OrganizationsController : Controller
organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
organization.UsePhishingBlocker = model.UsePhishingBlocker;
//secrets
organization.SmSeats = model.SmSeats;

View File

@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
UsePhishingBlocker = org.UsePhishingBlocker;
_plans = plans;
}
@ -160,6 +161,8 @@ public class OrganizationEditModel : OrganizationViewModel
public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; }
[Display(Name = "Phishing Blocker")]
public new bool UsePhishingBlocker { get; set; }
[Display(Name = "Admin Sponsored Families")]
public bool UseAdminSponsoredFamilies { get; set; }
[Display(Name = "Self Host")]
@ -327,6 +330,7 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
return existingOrganization;
}
}

View File

@ -75,6 +75,7 @@ public class OrganizationViewModel
public int OccupiedSmSeatsCount { get; set; }
public bool UseSecretsManager => Organization.UseSecretsManager;
public bool UseRiskInsights => Organization.UseRiskInsights;
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
}

View File

@ -156,6 +156,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">

View File

@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
public virtual async Task StartAsync(CancellationToken cancellationToken)
{
// Wait 20 seconds to allow database to come online
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
var maxMigrationAttempts = 10;
for (var i = 1; i <= maxMigrationAttempts; i++)
@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
}
}
}

View File

@ -1,8 +1,8 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")]
public class OrganizationIntegrationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository) : Controller
ICreateOrganizationIntegrationCommand createCommand,
IUpdateOrganizationIntegrationCommand updateCommand,
IDeleteOrganizationIntegrationCommand deleteCommand,
IGetOrganizationIntegrationsQuery getQuery) : Controller
{
[HttpGet("")]
public async Task<List<OrganizationIntegrationResponseModel>> GetAsync(Guid organizationId)
@ -22,7 +25,7 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
var integrations = await getQuery.GetManyByOrganizationAsync(organizationId);
return integrations
.Select(integration => new OrganizationIntegrationResponseModel(integration))
.ToList();
@ -36,8 +39,10 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
return new OrganizationIntegrationResponseModel(integration);
var integration = model.ToOrganizationIntegration(organizationId);
var created = await createCommand.CreateAsync(integration);
return new OrganizationIntegrationResponseModel(created);
}
[HttpPut("{integrationId:guid}")]
@ -48,14 +53,10 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var integration = model.ToOrganizationIntegration(organizationId);
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration);
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
return new OrganizationIntegrationResponseModel(integration);
return new OrganizationIntegrationResponseModel(updated);
}
[HttpDelete("{integrationId:guid}")]
@ -66,13 +67,7 @@ public class OrganizationIntegrationController(
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
await deleteCommand.DeleteAsync(organizationId, integrationId);
}
[HttpPost("{integrationId:guid}/delete")]

View File

@ -41,6 +41,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand;
using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
namespace Bit.Api.AdminConsole.Controllers;
@ -71,11 +73,13 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
@ -103,10 +107,12 @@ public class OrganizationUsersController : BaseAdminConsoleController
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -131,7 +137,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
_featureService = featureService;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
@ -273,7 +281,17 @@ public class OrganizationUsersController : BaseAdminConsoleController
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
var userId = _userService.GetProperUserId(User);
var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
{
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
}
else
{
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
}
return new ListResponseModel<OrganizationUserBulkResponseModel>(
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
}
@ -483,43 +501,10 @@ public class OrganizationUsersController : BaseAdminConsoleController
}
}
#nullable enable
[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
@ -662,7 +647,29 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
if (!_featureService.IsEnabled(FeatureFlagKeys.BulkRevokeUsersV2))
{
return await RestoreOrRevokeUsersAsync(orgId, model, _revokeOrganizationUserCommand.RevokeUsersAsync);
}
var currentUserId = _userService.GetProperUserId(User);
if (currentUserId == null)
{
throw new UnauthorizedAccessException();
}
var results = await _revokeOrganizationUserCommandVNext.RevokeUsersAsync(
new V2_RevokeOrganizationUserCommand.RevokeOrganizationUsersRequest(
orgId,
model.Ids.ToArray(),
new StandardUser(currentUserId.Value, await _currentContext.OrganizationOwner(orgId))));
return new ListResponseModel<OrganizationUserBulkResponseModel>(results
.Select(result => new OrganizationUserBulkResponseModel(result.Id,
result.Result.Match(
error => error.Message,
_ => string.Empty
))));
}
[HttpPatch("revoke")]

View File

@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
@ -70,6 +69,7 @@ public class OrganizationsController : Controller
private readonly IPolicyRequirementQuery _policyRequirementQuery;
private readonly IPricingClient _pricingClient;
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@ -94,7 +94,8 @@ public class OrganizationsController : Controller
IOrganizationDeleteCommand organizationDeleteCommand,
IPolicyRequirementQuery policyRequirementQuery,
IPricingClient pricingClient,
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
IOrganizationUpdateCommand organizationUpdateCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -119,6 +120,7 @@ public class OrganizationsController : Controller
_policyRequirementQuery = policyRequirementQuery;
_pricingClient = pricingClient;
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
_organizationUpdateCommand = organizationUpdateCommand;
}
[HttpGet("{id}")]
@ -224,36 +226,31 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(result.Organization, plan);
}
[HttpPut("{id}")]
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
[HttpPut("{organizationId:guid}")]
public async Task<IResult> Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model)
{
var orgIdGuid = new Guid(id);
// If billing email is being changed, require subscription editing permissions.
// Otherwise, organization owner permissions are sufficient.
var requiresBillingPermission = model.BillingEmail is not null;
var authorized = requiresBillingPermission
? await _currentContext.EditSubscription(organizationId)
: await _currentContext.OrganizationOwner(organizationId);
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
if (organization == null)
if (!authorized)
{
throw new NotFoundException();
return TypedResults.Unauthorized();
}
var updateBilling = ShouldUpdateBilling(model, organization);
var commandRequest = model.ToCommandRequest(organizationId);
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
var hasRequiredPermissions = updateBilling
? await _currentContext.EditSubscription(orgIdGuid)
: await _currentContext.OrganizationOwner(orgIdGuid);
if (!hasRequiredPermissions)
{
throw new NotFoundException();
}
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
var plan = await _pricingClient.GetPlan(organization.PlanType);
return new OrganizationResponseModel(organization, plan);
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
}
[HttpPost("{id}")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
{
return await Put(id, model);
}
@ -588,11 +585,4 @@ public class OrganizationsController : Controller
return organization.PlanType;
}
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
{
var organizationNameChanged = model.Name != organization.Name;
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
}
}

View File

@ -42,7 +42,6 @@ public class PoliciesController : Controller
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
private readonly IPolicyRepository _policyRepository;
private readonly IUserService _userService;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
@ -55,7 +54,6 @@ public class PoliciesController : Controller
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
IOrganizationRepository organizationRepository,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
@ -69,7 +67,6 @@ public class PoliciesController : Controller
_organizationRepository = organizationRepository;
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
@ -221,9 +218,7 @@ public class PoliciesController : Controller
{
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest);
return new PolicyResponseModel(policy);
}

View File

@ -1,41 +1,28 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Models.Data;
using Bit.Core.Settings;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
public class OrganizationUpdateRequestModel
{
[Required]
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string BusinessName { get; set; }
[EmailAddress]
[Required]
[StringLength(256)]
public string BillingEmail { get; set; }
public Permissions Permissions { get; set; }
public OrganizationKeysRequestModel Keys { get; set; }
public string? Name { get; set; }
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
[EmailAddress]
[StringLength(256)]
public string? BillingEmail { get; set; }
public OrganizationKeysRequestModel? Keys { get; set; }
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
{
if (!globalSettings.SelfHosted)
{
// These items come from the license file
existingOrganization.Name = Name;
existingOrganization.BusinessName = BusinessName;
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
}
Keys?.ToOrganization(existingOrganization);
return existingOrganization;
}
OrganizationId = organizationId,
Name = Name,
BillingEmail = BillingEmail,
PublicKey = Keys?.PublicKey,
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
};
}

View File

@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public class OrganizationUserBulkRequestModel
{
[Required]
[Required, MinLength(1)]
public IEnumerable<Guid> Ids { get; set; }
}

View File

@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }

View File

@ -1,10 +1,13 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Security.Claims;
using System.Text.Json.Serialization;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Models.Api;
using Bit.Core.Models.Business;
@ -71,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
public Guid Id { get; set; }
@ -120,6 +124,7 @@ public class OrganizationResponseModel : ResponseModel
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
}
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
@ -175,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
}
}
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) :
this(organization, (Plan)null)
{
if (license != null)
{
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
// The token's expiration is cryptographically secured and cannot be tampered with
// The file's Expires property can be manually edited and should NOT be trusted for display
if (claimsPrincipal != null)
{
Expiration = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Expires);
ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
}
else
{
// No token - use the license file expiration (for older licenses without tokens)
Expiration = license.Expires;
ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial
? license.Expires
: license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays));
}
}
}
public string StorageName { get; set; }
public double? StorageGb { get; set; }
public BillingCustomerDiscount CustomerDiscount { get; set; }

View File

@ -5,15 +5,10 @@ using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -24,25 +19,16 @@ namespace Bit.Api.AdminConsole.Public.Controllers;
public class PoliciesController : Controller
{
private readonly IPolicyRepository _policyRepository;
private readonly IPolicyService _policyService;
private readonly ICurrentContext _currentContext;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public PoliciesController(
IPolicyRepository policyRepository,
IPolicyService policyService,
ICurrentContext currentContext,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_policyRepository = policyRepository;
_policyService = policyService;
_currentContext = currentContext;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
@ -97,17 +83,8 @@ public class PoliciesController : Controller
[ProducesResponseType((int)HttpStatusCode.NotFound)]
public async Task<IActionResult> Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model)
{
Policy policy;
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
}
else
{
var policyUpdate = model.ToPolicyUpdate(_currentContext.OrganizationId!.Value, type);
policy = await _savePolicyCommand.SaveAsync(policyUpdate);
}
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
var response = new PolicyResponseModel(policy);
return new JsonResult(response);

View File

@ -33,7 +33,7 @@
<ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup>

View File

@ -26,7 +26,8 @@ public class AccountsController(
IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller
IFeatureService featureService,
ILicensingService licensingService) : Controller
{
[HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync(
@ -97,12 +98,14 @@ public class AccountsController(
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
return new SubscriptionResponseModel(user, subscriptionInfo, license, includeMilestone2Discount);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, subscriptionInfo, license, claimsPrincipal, includeMilestone2Discount);
}
else
{
var license = await userService.GenerateLicenseAsync(user);
return new SubscriptionResponseModel(user, license);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
return new SubscriptionResponseModel(user, null, license, claimsPrincipal);
}
}
else

View File

@ -67,7 +67,8 @@ public class OrganizationsController(
if (globalSettings.SelfHosted)
{
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
return new OrganizationSubscriptionResponseModel(organization, orgLicense);
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(orgLicense);
return new OrganizationSubscriptionResponseModel(organization, orgLicense, claimsPrincipal);
}
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);

View File

@ -66,7 +66,10 @@ public class HibpController : Controller
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
return new NotFoundResult();
/* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches,
an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could
not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */
return Content("[]", "application/json");
}
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
{

View File

@ -84,7 +84,7 @@ public class AccountsKeyManagementController : Controller
[HttpPost("key-management/regenerate-keys")]
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
{
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration) && !_featureService.IsEnabled(FeatureFlagKeys.DataRecoveryTool))
{
throw new NotFoundException();
}

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.KeyManagement.Models.Api.Request;
namespace Bit.Api.KeyManagement.Models.Requests;

View File

@ -1,4 +1,7 @@
using Bit.Core.Billing.Constants;
using System.Security.Claims;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Entities;
using Bit.Core.Models.Api;
@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel
: null;
}
/// <param name="user">The user entity containing storage and premium subscription information</param>
/// <param name="subscription">Subscription information retrieved from the payment provider (Stripe/Braintree)</param>
/// <param name="license">The user's license containing expiration and feature entitlements</param>
/// <param name="claimsPrincipal">The claims principal containing cryptographically secure token claims</param>
/// <param name="includeMilestone2Discount">
/// Whether to include discount information in the response.
/// Set to true when the PM23341_Milestone_2 feature flag is enabled AND
/// you want to expose Milestone 2 discount information to the client.
/// The discount will only be included if it matches the specific Milestone 2 coupon ID.
/// </param>
public SubscriptionResponseModel(User user, SubscriptionInfo? subscription, UserLicense license, ClaimsPrincipal? claimsPrincipal, bool includeMilestone2Discount = false)
: base("subscription")
{
Subscription = subscription?.Subscription != null ? new BillingSubscription(subscription.Subscription) : null;
UpcomingInvoice = subscription?.UpcomingInvoice != null ?
new BillingSubscriptionUpcomingInvoice(subscription.UpcomingInvoice) : null;
StorageName = user.Storage.HasValue ? CoreHelpers.ReadableBytesSize(user.Storage.Value) : null;
StorageGb = user.Storage.HasValue ? Math.Round(user.Storage.Value / 1073741824D, 2) : 0; // 1 GB
MaxStorageGb = user.MaxStorageGb;
License = license;
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
// The token's expiration is cryptographically secured and cannot be tampered with
// The file's Expires property can be manually edited and should NOT be trusted for display
if (claimsPrincipal != null)
{
Expiration = claimsPrincipal.GetValue<DateTime?>(UserLicenseConstants.Expires);
}
else
{
// No token - use the license file expiration (for older licenses without tokens)
Expiration = License.Expires;
}
// Only display the Milestone 2 subscription discount on the subscription page.
CustomerDiscount = ShouldIncludeMilestone2Discount(includeMilestone2Discount, subscription?.CustomerDiscount)
? new BillingCustomerDiscount(subscription!.CustomerDiscount!)
: null;
}
public SubscriptionResponseModel(User user, UserLicense? license = null)
: base("subscription")
{

View File

@ -0,0 +1,337 @@
using Bit.Api.Models.Response;
using Bit.Api.SecretsManager.Models.Request;
using Bit.Api.SecretsManager.Models.Response;
using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.SecretsManager.Controllers;
[Authorize("secrets")]
public class SecretVersionsController : Controller
{
private readonly ICurrentContext _currentContext;
private readonly ISecretVersionRepository _secretVersionRepository;
private readonly ISecretRepository _secretRepository;
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
public SecretVersionsController(
ICurrentContext currentContext,
ISecretVersionRepository secretVersionRepository,
ISecretRepository secretRepository,
IUserService userService,
IOrganizationUserRepository organizationUserRepository)
{
_currentContext = currentContext;
_secretVersionRepository = secretVersionRepository;
_secretRepository = secretRepository;
_userService = userService;
_organizationUserRepository = organizationUserRepository;
}
[HttpGet("secrets/{secretId}/versions")]
public async Task<ListResponseModel<SecretVersionResponseModel>> GetVersionsBySecretIdAsync([FromRoute] Guid secretId)
{
var secret = await _secretRepository.GetByIdAsync(secretId);
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access above
var versionList = await _secretVersionRepository.GetManyBySecretIdAsync(secretId);
var responseList = versionList.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(responseList);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient);
if (!access.Read)
{
throw new NotFoundException();
}
var versions = await _secretVersionRepository.GetManyBySecretIdAsync(secretId);
var responses = versions.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(responses);
}
[HttpGet("secret-versions/{id}")]
public async Task<SecretVersionResponseModel> GetByIdAsync([FromRoute] Guid id)
{
var secretVersion = await _secretVersionRepository.GetByIdAsync(id);
if (secretVersion == null)
{
throw new NotFoundException();
}
var secret = await _secretRepository.GetByIdAsync(secretVersion.SecretId);
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access above
return new SecretVersionResponseModel(secretVersion);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var access = await _secretRepository.AccessToSecretAsync(secretVersion.SecretId, userId.Value, accessClient);
if (!access.Read)
{
throw new NotFoundException();
}
return new SecretVersionResponseModel(secretVersion);
}
[HttpPost("secret-versions/get-by-ids")]
public async Task<ListResponseModel<SecretVersionResponseModel>> GetManyByIdsAsync([FromBody] List<Guid> ids)
{
if (!ids.Any())
{
throw new BadRequestException("No version IDs provided.");
}
// Get all versions
var versions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList();
if (!versions.Any())
{
throw new NotFoundException();
}
// Get all associated secrets and check permissions
var secretIds = versions.Select(v => v.SecretId).Distinct().ToList();
var secrets = (await _secretRepository.GetManyByIds(secretIds)).ToList();
if (!secrets.Any())
{
throw new NotFoundException();
}
// Ensure all secrets belong to the same organization
var organizationId = secrets.First().OrganizationId;
if (secrets.Any(s => s.OrganizationId != organizationId) ||
!_currentContext.AccessSecretsManager(organizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access and organization ownership above
var serviceAccountResponses = versions.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(serviceAccountResponses);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var isAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, isAdmin);
// Verify read access to all associated secrets
var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient);
if (accessResults.Values.Any(access => !access.Read))
{
throw new NotFoundException();
}
var responses = versions.Select(v => new SecretVersionResponseModel(v));
return new ListResponseModel<SecretVersionResponseModel>(responses);
}
[HttpPut("secrets/{secretId}/versions/restore")]
public async Task<SecretResponseModel> RestoreVersionAsync([FromRoute] Guid secretId, [FromBody] RestoreSecretVersionRequestModel request)
{
if (!(_currentContext.IdentityClientType == IdentityClientType.User || _currentContext.IdentityClientType == IdentityClientType.ServiceAccount))
{
throw new NotFoundException();
}
var secret = await _secretRepository.GetByIdAsync(secretId);
if (secret == null || !_currentContext.AccessSecretsManager(secret.OrganizationId))
{
throw new NotFoundException();
}
// Get the version first to validate it belongs to this secret
var version = await _secretVersionRepository.GetByIdAsync(request.VersionId);
if (version == null || version.SecretId != secretId)
{
throw new NotFoundException();
}
// Store the current value before restoration
var currentValue = secret.Value;
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount)
{
// Save current value as a version before restoring
if (currentValue != version.Value)
{
var editorUserId = _userService.GetProperUserId(User);
if (editorUserId.HasValue)
{
var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion
{
SecretId = secretId,
Value = currentValue!,
VersionDate = DateTime.UtcNow,
EditorServiceAccountId = editorUserId.Value
};
await _secretVersionRepository.CreateAsync(currentVersionSnapshot);
}
}
// Already verified Secrets Manager access above
secret.Value = version.Value;
secret.RevisionDate = DateTime.UtcNow;
var updatedSec = await _secretRepository.UpdateAsync(secret);
return new SecretResponseModel(updatedSec, true, true);
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(secret.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var access = await _secretRepository.AccessToSecretAsync(secretId, userId.Value, accessClient);
if (!access.Write)
{
throw new NotFoundException();
}
// Save current value as a version before restoring
if (currentValue != version.Value)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId.Value);
if (orgUser == null)
{
throw new NotFoundException();
}
var currentVersionSnapshot = new Core.SecretsManager.Entities.SecretVersion
{
SecretId = secretId,
Value = currentValue!,
VersionDate = DateTime.UtcNow,
EditorOrganizationUserId = orgUser.Id
};
await _secretVersionRepository.CreateAsync(currentVersionSnapshot);
}
// Update the secret with the version's value
secret.Value = version.Value;
secret.RevisionDate = DateTime.UtcNow;
var updatedSecret = await _secretRepository.UpdateAsync(secret);
return new SecretResponseModel(updatedSecret, true, true);
}
[HttpPost("secret-versions/delete")]
public async Task<IActionResult> BulkDeleteAsync([FromBody] List<Guid> ids)
{
if (!ids.Any())
{
throw new BadRequestException("No version IDs provided.");
}
var secretVersions = (await _secretVersionRepository.GetManyByIdsAsync(ids)).ToList();
if (secretVersions.Count != ids.Count)
{
throw new NotFoundException();
}
// Ensure all versions belong to secrets in the same organization
var secretIds = secretVersions.Select(v => v.SecretId).Distinct().ToList();
var secrets = await _secretRepository.GetManyByIds(secretIds);
var secretsList = secrets.ToList();
if (!secretsList.Any())
{
throw new NotFoundException();
}
var organizationId = secretsList.First().OrganizationId;
if (secretsList.Any(s => s.OrganizationId != organizationId) ||
!_currentContext.AccessSecretsManager(organizationId))
{
throw new NotFoundException();
}
// For service accounts and organization API, skip user-level access checks
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount ||
_currentContext.IdentityClientType == IdentityClientType.Organization)
{
// Already verified Secrets Manager access and organization ownership above
await _secretVersionRepository.DeleteManyByIdAsync(ids);
return Ok();
}
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
// Verify write access to all associated secrets
var accessResults = await _secretRepository.AccessToSecretsAsync(secretIds, userId.Value, accessClient);
if (accessResults.Values.Any(access => !access.Write))
{
throw new NotFoundException();
}
await _secretVersionRepository.DeleteManyByIdAsync(ids);
return Ok();
}
}

View File

@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
@ -29,6 +30,7 @@ public class SecretsController : Controller
private readonly ICurrentContext _currentContext;
private readonly IProjectRepository _projectRepository;
private readonly ISecretRepository _secretRepository;
private readonly ISecretVersionRepository _secretVersionRepository;
private readonly ICreateSecretCommand _createSecretCommand;
private readonly IUpdateSecretCommand _updateSecretCommand;
private readonly IDeleteSecretCommand _deleteSecretCommand;
@ -38,11 +40,13 @@ public class SecretsController : Controller
private readonly IUserService _userService;
private readonly IEventService _eventService;
private readonly IAuthorizationService _authorizationService;
private readonly IOrganizationUserRepository _organizationUserRepository;
public SecretsController(
ICurrentContext currentContext,
IProjectRepository projectRepository,
ISecretRepository secretRepository,
ISecretVersionRepository secretVersionRepository,
ICreateSecretCommand createSecretCommand,
IUpdateSecretCommand updateSecretCommand,
IDeleteSecretCommand deleteSecretCommand,
@ -51,11 +55,13 @@ public class SecretsController : Controller
ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery,
IUserService userService,
IEventService eventService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IOrganizationUserRepository organizationUserRepository)
{
_currentContext = currentContext;
_projectRepository = projectRepository;
_secretRepository = secretRepository;
_secretVersionRepository = secretVersionRepository;
_createSecretCommand = createSecretCommand;
_updateSecretCommand = updateSecretCommand;
_deleteSecretCommand = deleteSecretCommand;
@ -65,6 +71,7 @@ public class SecretsController : Controller
_userService = userService;
_eventService = eventService;
_authorizationService = authorizationService;
_organizationUserRepository = organizationUserRepository;
}
@ -190,6 +197,44 @@ public class SecretsController : Controller
}
}
// Create a version record if the value changed
if (updateRequest.ValueChanged)
{
// Store the old value before updating
var oldValue = secret.Value;
var userId = _userService.GetProperUserId(User)!.Value;
Guid? editorServiceAccountId = null;
Guid? editorOrganizationUserId = null;
if (_currentContext.IdentityClientType == IdentityClientType.ServiceAccount)
{
editorServiceAccountId = userId;
}
else if (_currentContext.IdentityClientType == IdentityClientType.User)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(secret.OrganizationId, userId);
if (orgUser != null)
{
editorOrganizationUserId = orgUser.Id;
}
else
{
throw new NotFoundException();
}
}
var secretVersion = new SecretVersion
{
SecretId = id,
Value = oldValue,
VersionDate = DateTime.UtcNow,
EditorServiceAccountId = editorServiceAccountId,
EditorOrganizationUserId = editorOrganizationUserId
};
await _secretVersionRepository.CreateAsync(secretVersion);
}
var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates);
await LogSecretEventAsync(secret, EventType.Secret_Edited);

View File

@ -0,0 +1,9 @@
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.SecretsManager.Models.Request;
public class RestoreSecretVersionRequestModel
{
[Required]
public Guid VersionId { get; set; }
}

View File

@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject
public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; }
public bool ValueChanged { get; set; } = false;
public Secret ToSecret(Secret secret)
{
secret.Key = Key;

View File

@ -0,0 +1,28 @@
using Bit.Core.Models.Api;
using Bit.Core.SecretsManager.Entities;
namespace Bit.Api.SecretsManager.Models.Response;
public class SecretVersionResponseModel : ResponseModel
{
private const string _objectName = "secretVersion";
public Guid Id { get; set; }
public Guid SecretId { get; set; }
public string Value { get; set; } = string.Empty;
public DateTime VersionDate { get; set; }
public Guid? EditorServiceAccountId { get; set; }
public Guid? EditorOrganizationUserId { get; set; }
public SecretVersionResponseModel() : base(_objectName) { }
public SecretVersionResponseModel(SecretVersion secretVersion) : base(_objectName)
{
Id = secretVersion.Id;
SecretId = secretVersion.SecretId;
Value = secretVersion.Value;
VersionDate = secretVersion.VersionDate;
EditorServiceAccountId = secretVersion.EditorServiceAccountId;
EditorOrganizationUserId = secretVersion.EditorOrganizationUserId;
}
}

View File

@ -216,7 +216,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention());
});
services.AddSwagger(globalSettings, Environment);
services.AddSwaggerGen(globalSettings, Environment);
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
services.AddHostedService<Jobs.JobsHostedService>();
@ -226,7 +226,8 @@ public class Startup
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
}
// Add Slack / Teams Services for OAuth API requests - if configured
// Add Event Integrations services
services.AddEventIntegrationsCommandsQueries(globalSettings);
services.AddSlackService(globalSettings);
services.AddTeamsService(globalSettings);
}
@ -292,17 +293,59 @@ public class Startup
});
// Add Swagger
// Note that the swagger.json generation is configured in the call to AddSwaggerGen above.
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
{
// adds the middleware to serve the swagger.json while the server is running
app.UseSwagger(config =>
{
config.RouteTemplate = "specs/{documentName}/swagger.json";
// Remove all Bitwarden cloud servers and only register the local server
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
swaggerDoc.Servers = new List<OpenApiServer>
{
swaggerDoc.Servers.Clear();
swaggerDoc.Servers.Add(new OpenApiServer
{
new OpenApiServer { Url = globalSettings.BaseServiceUri.Api }
Url = globalSettings.BaseServiceUri.Api,
});
swaggerDoc.Components.SecuritySchemes.Clear();
swaggerDoc.Components.SecuritySchemes.Add("oauth2-client-credentials", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" }
}
}
}
});
swaggerDoc.SecurityRequirements.Clear();
swaggerDoc.SecurityRequirements.Add(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
}
},
[ApiScopes.ApiOrganization]
}
});
});
});
// adds the middleware to display the web UI
app.UseSwaggerUI(config =>
{
config.DocumentTitle = "Bitwarden API Documentation";

View File

@ -1,6 +1,5 @@
using Bit.Api.AdminConsole.Authorization;
using Bit.Api.Tools.Authorization;
using Bit.Core.Auth.IdentityServer;
using Bit.Core.PhishingDomainFeatures;
using Bit.Core.PhishingDomainFeatures.Interfaces;
using Bit.Core.Repositories;
@ -10,6 +9,7 @@ using Bit.Core.Utilities;
using Bit.Core.Vault.Authorization.SecurityTasks;
using Bit.SharedWeb.Health;
using Bit.SharedWeb.Swagger;
using Bit.SharedWeb.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.OpenApi.Models;
@ -17,7 +17,10 @@ namespace Bit.Api.Utilities;
public static class ServiceCollectionExtensions
{
public static void AddSwagger(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
/// <summary>
/// Configures the generation of swagger.json OpenAPI spec.
/// </summary>
public static void AddSwaggerGen(this IServiceCollection services, GlobalSettings globalSettings, IWebHostEnvironment environment)
{
services.AddSwaggerGen(config =>
{
@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions
organizations tools for managing members, collections, groups, event logs, and policies.
If you are looking for the Vault Management API, refer instead to
[this document](https://bitwarden.com/help/vault-management-api/).
**Note:** your authorization must match the server you have selected.
""",
License = new OpenApiLicense
{
@ -46,36 +51,20 @@ public static class ServiceCollectionExtensions
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
ClientCredentials = new OpenApiOAuthFlow
{
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
Scopes = new Dictionary<string, string>
{
{ ApiScopes.ApiOrganization, "Organization APIs" },
},
}
},
});
// Configure Bitwarden cloud US and EU servers. These will appear in the swagger.json build artifact
// used for our help center. These are overwritten with the local server when running in self-hosted
// or dev mode (see Api Startup.cs).
config.AddSwaggerServerWithSecurity(
serverId: "US_server",
serverUrl: "https://api.bitwarden.com",
identityTokenUrl: "https://identity.bitwarden.com/connect/token",
serverDescription: "US server");
config.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "oauth2-client-credentials"
},
},
new[] { ApiScopes.ApiOrganization }
}
});
config.AddSwaggerServerWithSecurity(
serverId: "EU_server",
serverUrl: "https://api.bitwarden.eu",
identityTokenUrl: "https://identity.bitwarden.eu/connect/token",
serverDescription: "EU server");
config.DescribeAllParametersInCamelCase();
// config.UseReferencedDefinitionsForEnums();

View File

@ -757,11 +757,6 @@ public class CiphersController : Controller
}
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move an archived item to an organization.");
}
ValidateClientVersionForFido2CredentialSupport(cipher);
var original = cipher.Clone();
@ -1271,11 +1266,6 @@ public class CiphersController : Controller
_logger.LogError("Cipher was not encrypted for the current user. CipherId: {CipherId}, CurrentUser: {CurrentUserId}, EncryptedFor: {EncryptedFor}", cipher.Id, userId, cipher.EncryptedFor);
throw new BadRequestException("Cipher was not encrypted for the current user. Please try again.");
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
}
var shareCiphers = new List<(CipherDetails, DateTime?)>();
@ -1288,11 +1278,6 @@ public class CiphersController : Controller
ValidateClientVersionForFido2CredentialSupport(existingCipher);
if (existingCipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cannot move archived items to an organization.");
}
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
}

View File

@ -0,0 +1,36 @@
using Bit.Billing.Jobs;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Billing.Controllers;
[Route("jobs")]
[SelfHosted(NotSelfHostedOnly = true)]
[RequireLowerEnvironment]
public class JobsController(
JobsHostedService jobsHostedService) : Controller
{
[HttpPost("run/{jobName}")]
public async Task<IActionResult> RunJobAsync(string jobName)
{
if (jobName == nameof(ReconcileAdditionalStorageJob))
{
await jobsHostedService.RunJobAdHocAsync<ReconcileAdditionalStorageJob>();
return Ok(new { message = $"Job {jobName} scheduled successfully" });
}
return BadRequest(new { error = $"Unknown job name: {jobName}" });
}
[HttpPost("stop/{jobName}")]
public async Task<IActionResult> StopJobAsync(string jobName)
{
if (jobName == nameof(ReconcileAdditionalStorageJob))
{
await jobsHostedService.InterruptAdHocJobAsync<ReconcileAdditionalStorageJob>();
return Ok(new { message = $"Job {jobName} queued for cancellation" });
}
return BadRequest(new { error = $"Unknown job name: {jobName}" });
}
}

View File

@ -10,4 +10,13 @@ public class AliveJob(ILogger<AliveJob> logger) : BaseJob(logger)
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
return Task.FromResult(0);
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()
.WithIdentity("EveryTopOfTheHourTrigger")
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
}
}

View File

@ -1,29 +1,27 @@
using Bit.Core.Jobs;
using Bit.Core.Exceptions;
using Bit.Core.Jobs;
using Bit.Core.Settings;
using Quartz;
namespace Bit.Billing.Jobs;
public class JobsHostedService : BaseJobsHostedService
public class JobsHostedService(
GlobalSettings globalSettings,
IServiceProvider serviceProvider,
ILogger<JobsHostedService> logger,
ILogger<JobListener> listenerLogger,
ISchedulerFactory schedulerFactory)
: BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger)
{
public JobsHostedService(
GlobalSettings globalSettings,
IServiceProvider serviceProvider,
ILogger<JobsHostedService> logger,
ILogger<JobListener> listenerLogger)
: base(globalSettings, serviceProvider, logger, listenerLogger) { }
private List<JobKey> AdHocJobKeys { get; } = [];
private IScheduler? _adHocScheduler;
public override async Task StartAsync(CancellationToken cancellationToken)
{
var everyTopOfTheHourTrigger = TriggerBuilder.Create()
.WithIdentity("EveryTopOfTheHourTrigger")
.StartNow()
.WithCronSchedule("0 0 * * * ?")
.Build();
Jobs = new List<Tuple<Type, ITrigger>>
{
new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger)
new(typeof(AliveJob), AliveJob.GetTrigger()),
new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger())
};
await base.StartAsync(cancellationToken);
@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService
{
services.AddTransient<AliveJob>();
services.AddTransient<SubscriptionCancellationJob>();
services.AddTransient<ReconcileAdditionalStorageJob>();
// add this service as a singleton so we can inject it where needed
services.AddSingleton<JobsHostedService>();
services.AddHostedService(sp => sp.GetRequiredService<JobsHostedService>());
}
public async Task InterruptAdHocJobAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
{
if (_adHocScheduler == null)
{
throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job.");
}
var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString());
if (jobKey == null)
{
throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?");
}
logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey);
AdHocJobKeys.Remove(jobKey);
await _adHocScheduler.Interrupt(jobKey, cancellationToken);
}
public async Task RunJobAdHocAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
{
_adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken);
var jobKey = new JobKey(typeof(T).ToString());
var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken);
if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey)))
{
throw new InvalidOperationException($"Job {jobKey} is already running");
}
AdHocJobKeys.Add(jobKey);
var job = JobBuilder.Create<T>()
.WithIdentity(jobKey)
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity(typeof(T).ToString())
.StartNow()
.Build();
logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey);
await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken);
}
}

View File

@ -0,0 +1,207 @@
using System.Globalization;
using System.Text.Json;
using Bit.Billing.Services;
using Bit.Core;
using Bit.Core.Billing.Constants;
using Bit.Core.Jobs;
using Bit.Core.Services;
using Quartz;
using Stripe;
namespace Bit.Billing.Jobs;
public class ReconcileAdditionalStorageJob(
IStripeFacade stripeFacade,
ILogger<ReconcileAdditionalStorageJob> logger,
IFeatureService featureService) : BaseJob(logger)
{
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
private const int _storageGbToRemove = 4;
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
{
if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob))
{
logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off.");
return;
}
var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode);
// Execution tracking
var subscriptionsFound = 0;
var subscriptionsUpdated = 0;
var subscriptionsWithErrors = 0;
var failures = new List<string>();
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
foreach (var priceId in priceIds)
{
var options = new SubscriptionListOptions
{
Limit = 100,
Status = StripeConstants.SubscriptionStatus.Active,
Price = priceId
};
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
{
if (context.CancellationToken.IsCancellationRequested)
{
logger.LogWarning(
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
return;
}
if (subscription == null)
{
continue;
}
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
subscriptionsFound++;
if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true)
{
if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed))
{
logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}",
subscription.Id,
dateProcessed.ToString("f"));
continue;
}
}
var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId);
if (updateOptions == null)
{
logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id);
continue;
}
subscriptionsUpdated++;
if (!liveMode)
{
logger.LogInformation(
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
subscription.Id,
Environment.NewLine,
JsonSerializer.Serialize(updateOptions));
continue;
}
try
{
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
}
catch (Exception ex)
{
subscriptionsWithErrors++;
failures.Add($"Subscription {subscription.Id}: {ex.Message}");
logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}",
subscription.Id, ex.Message);
}
}
}
logger.LogInformation(
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
subscriptionsFound,
liveMode
? subscriptionsUpdated
: $"(In live mode, would have updated) {subscriptionsUpdated}",
subscriptionsWithErrors,
failures.Count > 0
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
: string.Empty
);
}
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
Subscription subscription,
string targetPriceId)
{
if (subscription.Items?.Data == null)
{
return null;
}
var updateOptions = new SubscriptionUpdateOptions
{
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
},
Items = []
};
var hasUpdates = false;
foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId))
{
hasUpdates = true;
var currentQuantity = item.Quantity;
if (currentQuantity > _storageGbToRemove)
{
var newQuantity = currentQuantity - _storageGbToRemove;
logger.LogInformation(
"Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}",
subscription.Id,
currentQuantity,
newQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Quantity = newQuantity
});
}
else
{
logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}",
subscription.Id,
currentQuantity,
item.Price.Id);
updateOptions.Items.Add(new SubscriptionItemOptions
{
Id = item.Id,
Deleted = true
});
}
}
return hasUpdates ? updateOptions : null;
}
public static ITrigger GetTrigger()
{
return TriggerBuilder.Create()
.WithIdentity("EveryMorningTrigger")
.StartNow()
.WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time
.Build();
}
}

View File

@ -1,16 +1,17 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Billing.Services;
using Bit.Billing.Services;
using Bit.Core.Billing.Constants;
using Bit.Core.Repositories;
using Quartz;
using Stripe;
namespace Bit.Billing.Jobs;
using static StripeConstants;
public class SubscriptionCancellationJob(
IStripeFacade stripeFacade,
IOrganizationRepository organizationRepository)
IOrganizationRepository organizationRepository,
ILogger<SubscriptionCancellationJob> logger)
: IJob
{
public async Task Execute(IJobExecutionContext context)
@ -21,20 +22,31 @@ public class SubscriptionCancellationJob(
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null || organization.Enabled)
{
logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because organization is either null or enabled", nameof(SubscriptionCancellationJob), subscriptionId);
// Organization was deleted or re-enabled by CS, skip cancellation
return;
}
var subscription = await stripeFacade.GetSubscription(subscriptionId);
if (subscription?.Status != "unpaid" ||
subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create"))
var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions
{
Expand = ["latest_invoice"]
});
if (subscription is not
{
Status: SubscriptionStatus.Unpaid,
LatestInvoice: { BillingReason: BillingReasons.SubscriptionCreate or BillingReasons.SubscriptionCycle }
})
{
logger.LogWarning("{Job} skipped for subscription ({SubscriptionID}) because subscription is not unpaid or does not have a cancellable billing reason", nameof(SubscriptionCancellationJob), subscriptionId);
return;
}
// Cancel the subscription
await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId);
// Void any open invoices
var options = new InvoiceListOptions
{
@ -46,6 +58,7 @@ public class SubscriptionCancellationJob(
foreach (var invoice in invoices)
{
await stripeFacade.VoidInvoice(invoice.Id);
logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId);
}
while (invoices.HasMore)
@ -55,6 +68,7 @@ public class SubscriptionCancellationJob(
foreach (var invoice in invoices)
{
await stripeFacade.VoidInvoice(invoice.Id);
logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId);
}
}
}

View File

@ -78,6 +78,11 @@ public interface IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
SubscriptionListOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,
@ -111,4 +116,10 @@ public interface IStripeFacade
TestClockGetOptions testClockGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
Task<Coupon> GetCoupon(
string couponId,
CouponGetOptions couponGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default);
}

View File

@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade
private readonly DiscountService _discountService = new();
private readonly SetupIntentService _setupIntentService = new();
private readonly TestClockService _testClockService = new();
private readonly CouponService _couponService = new();
public async Task<Charge> GetCharge(
string chargeId,
@ -98,6 +99,12 @@ public class StripeFacade : IStripeFacade
CancellationToken cancellationToken = default) =>
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
public IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
SubscriptionListOptions options = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken);
public async Task<Subscription> GetSubscription(
string subscriptionId,
SubscriptionGetOptions subscriptionGetOptions = null,
@ -137,4 +144,11 @@ public class StripeFacade : IStripeFacade
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
public Task<Coupon> GetCoupon(
string couponId,
CouponGetOptions couponGetOptions = null,
RequestOptions requestOptions = null,
CancellationToken cancellationToken = default) =>
_couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken);
}

View File

@ -1,7 +1,5 @@
using System.Globalization;
using Bit.Billing.Constants;
using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
@ -134,11 +132,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
case StripeSubscriptionStatus.Active when providerId.HasValue:
{
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
if (!providerPortalTakeover)
{
break;
}
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
@ -321,13 +314,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
Event parsedEvent,
Subscription currentSubscription)
{
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
if (!providerPortalTakeover)
{
return;
}
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
@ -343,22 +329,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
{
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
var updateIsSubscriptionGoingUnpaid = previousSubscription is
{
Status:
if (previousSubscription is
{
Status:
StripeSubscriptionStatus.Trialing or
StripeSubscriptionStatus.Active or
StripeSubscriptionStatus.PastDue
} && currentSubscription is
{
Status: StripeSubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
};
var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata(
previousSubscription, currentSubscription);
if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata)
} && currentSubscription is
{
Status: StripeSubscriptionStatus.Unpaid,
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
})
{
if (currentSubscription.TestClock != null)
{
@ -369,14 +350,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
if (updateIsManualSuspensionViaMetadata)
{
subscriptionUpdateOptions.Metadata = new Dictionary<string, string>
{
["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)
};
}
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
}
}
@ -399,37 +372,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
}
}
}
private static bool CheckForManualSuspensionViaMetadata(
Subscription? previousSubscription,
Subscription currentSubscription)
{
/*
* When metadata on a subscription is updated, we'll receive an event that has:
* Previous Metadata: { newlyAddedKey: null }
* Current Metadata: { newlyAddedKey: newlyAddedValue }
*
* As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the
* 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null.
*
* If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue',
* we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update
* that does not update the metadata) the same as a manual suspension.
*/
const string key = "suspend_provider";
if (previousSubscription is not { Metadata: not null } ||
!previousSubscription.Metadata.TryGetValue(key, out var previousValue))
{
return false;
}
if (previousValue == null)
{
return !string.IsNullOrEmpty(
currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null);
}
return false;
}
}

View File

@ -1,4 +1,5 @@
using Bit.Core;
using System.Globalization;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
@ -8,7 +9,9 @@ using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Entities;
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
using Bit.Core.Platform.Mail.Mailer;
using Bit.Core.Repositories;
@ -16,6 +19,7 @@ using Bit.Core.Services;
using Stripe;
using Event = Stripe.Event;
using Plan = Bit.Core.Models.StaticStore.Plan;
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
namespace Bit.Billing.Services.Implementations;
@ -107,13 +111,22 @@ public class UpcomingInvoiceHandler(
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
await AlignOrganizationSubscriptionConcernsAsync(
var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync(
organization,
@event,
subscription,
plan,
milestone3);
/*
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
* with processing.
*/
if (subscriptionAligned)
{
return;
}
// Don't send the upcoming invoice email unless the organization's on an annual plan.
if (!plan.IsAnnual)
{
@ -135,9 +148,7 @@ public class UpcomingInvoiceHandler(
}
}
await (milestone3
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
}
private async Task AlignOrganizationTaxConcernsAsync(
@ -188,7 +199,16 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignOrganizationSubscriptionConcernsAsync(
/// <summary>
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
/// </summary>
/// <param name="organization">The organization whose subscription is being updated.</param>
/// <param name="event">The Stripe event associated with this operation.</param>
/// <param name="subscription">The organization's subscription.</param>
/// <param name="plan">The organization's current plan.</param>
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
/// <returns>Whether the operation resulted in an updated subscription.</returns>
private async Task<bool> AlignOrganizationSubscriptionConcernsAsync(
Organization organization,
Event @event,
Subscription subscription,
@ -198,7 +218,7 @@ public class UpcomingInvoiceHandler(
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
{
return;
return false;
}
var passwordManagerItem =
@ -208,15 +228,15 @@ public class UpcomingInvoiceHandler(
{
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
organization.Id, @event.Type, @event.Id);
return;
return false;
}
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
organization.PlanType = families.Type;
organization.Plan = families.Name;
organization.UsersGetPremium = families.UsersGetPremium;
organization.Seats = families.PasswordManager.BaseSeats;
organization.PlanType = familiesPlan.Type;
organization.Plan = familiesPlan.Name;
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
var options = new SubscriptionUpdateOptions
{
@ -225,7 +245,7 @@ public class UpcomingInvoiceHandler(
new SubscriptionItemOptions
{
Id = passwordManagerItem.Id,
Price = families.PasswordManager.StripePlanId
Price = familiesPlan.PasswordManager.StripePlanId
}
],
ProrationBehavior = ProrationBehavior.None
@ -266,6 +286,8 @@ public class UpcomingInvoiceHandler(
{
await organizationRepository.ReplaceAsync(organization);
await stripeFacade.UpdateSubscription(subscription.Id, options);
await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan);
return true;
}
catch (Exception exception)
{
@ -275,6 +297,7 @@ public class UpcomingInvoiceHandler(
organization.Id,
@event.Type,
@event.Id);
return false;
}
}
@ -303,14 +326,21 @@ public class UpcomingInvoiceHandler(
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
if (milestone2Feature)
{
await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
/*
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
* with processing.
*/
if (subscriptionAligned)
{
return;
}
}
if (user.Premium)
{
await (milestone2Feature
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
}
}
@ -341,7 +371,7 @@ public class UpcomingInvoiceHandler(
}
}
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
User user,
Event @event,
Subscription subscription)
@ -352,7 +382,7 @@ public class UpcomingInvoiceHandler(
{
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
user.Id, @event.Type, @event.Id);
return;
return false;
}
try
@ -371,6 +401,8 @@ public class UpcomingInvoiceHandler(
],
ProrationBehavior = ProrationBehavior.None
});
await SendPremiumRenewalEmailAsync(user, plan);
return true;
}
catch (Exception exception)
{
@ -379,6 +411,7 @@ public class UpcomingInvoiceHandler(
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
user.Id,
@event.Id);
return false;
}
}
@ -513,15 +546,92 @@ public class UpcomingInvoiceHandler(
}
}
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
private async Task SendFamiliesRenewalEmailAsync(
Organization organization,
Plan familiesPlan,
Plan planBeforeAlignment)
{
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
await (planBeforeAlignment switch
{
ToEmails = validEmails,
View = new UpdatedInvoiceUpcomingView()
{ Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan),
{ Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan),
_ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().")
});
}
private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan)
{
var email = new Families2020RenewalMail
{
ToEmails = [organization.BillingEmail],
View = new Families2020RenewalMailView
{
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
}
};
await mailer.SendEmail(updatedUpcomingEmail);
await mailer.SendEmail(email);
}
private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan)
{
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
if (coupon == null)
{
throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found");
}
if (coupon.PercentOff == null)
{
throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null");
}
var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100;
var email = new Families2019RenewalMail
{
ToEmails = [organization.BillingEmail],
View = new Families2019RenewalMailView
{
BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")),
BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")),
DiscountAmount = $"{coupon.PercentOff}%",
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
}
};
await mailer.SendEmail(email);
}
private async Task SendPremiumRenewalEmailAsync(
User user,
PremiumPlan premiumPlan)
{
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
if (coupon == null)
{
throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found");
}
if (coupon.PercentOff == null)
{
throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null");
}
var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
var email = new PremiumRenewalMail
{
ToEmails = [user.Email],
View = new PremiumRenewalMailView
{
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
DiscountAmount = $"{coupon.PercentOff}%",
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
}
};
await mailer.SendEmail(email);
}
#endregion

View File

@ -134,6 +134,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
/// </summary>
public bool UseAutomaticUserConfirmation { get; set; }
/// <summary>
/// If set to true, the organization has phishing protection enabled.
/// </summary>
public bool UsePhishingBlocker { get; set; }
public void SetNewId()
{
if (Id == default(Guid))
@ -334,5 +339,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
UseOrganizationDomains = license.UseOrganizationDomains;
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
UsePhishingBlocker = license.UsePhishingBlocker;
}
}

View File

@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegration : ITableObject<Guid>

View File

@ -2,8 +2,6 @@
using Bit.Core.Enums;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.AdminConsole.Entities;
public class OrganizationIntegrationConfiguration : ITableObject<Guid>

View File

@ -0,0 +1,38 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
public static class EventIntegrationsServiceCollectionExtensions
{
/// <summary>
/// Adds all event integrations commands, queries, and required cache infrastructure.
/// This method is idempotent and can be called multiple times safely.
/// </summary>
public static IServiceCollection AddEventIntegrationsCommandsQueries(
this IServiceCollection services,
GlobalSettings globalSettings)
{
// Ensure cache is registered first - commands depend on this keyed cache.
// This is idempotent for the same named cache, so it's safe to call.
services.AddExtendedCache(EventIntegrationsCacheConstants.CacheName, globalSettings);
// Add all commands/queries
services.AddOrganizationIntegrationCommandsQueries();
return services;
}
internal static IServiceCollection AddOrganizationIntegrationCommandsQueries(this IServiceCollection services)
{
services.TryAddScoped<ICreateOrganizationIntegrationCommand, CreateOrganizationIntegrationCommand>();
services.TryAddScoped<IUpdateOrganizationIntegrationCommand, UpdateOrganizationIntegrationCommand>();
services.TryAddScoped<IDeleteOrganizationIntegrationCommand, DeleteOrganizationIntegrationCommand>();
services.TryAddScoped<IGetOrganizationIntegrationsQuery, GetOrganizationIntegrationsQuery>();
return services;
}
}

View File

@ -0,0 +1,38 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Command implementation for creating organization integrations with cache invalidation support.
/// </summary>
public class CreateOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
IFusionCache cache)
: ICreateOrganizationIntegrationCommand
{
public async Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration)
{
var existingIntegrations = await integrationRepository
.GetManyByOrganizationAsync(integration.OrganizationId);
if (existingIntegrations.Any(i => i.Type == integration.Type))
{
throw new BadRequestException("An integration of this type already exists for this organization.");
}
var created = await integrationRepository.CreateAsync(integration);
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: integration.OrganizationId,
integrationType: integration.Type
));
return created;
}
}

View File

@ -0,0 +1,33 @@
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Command implementation for deleting organization integrations with cache invalidation support.
/// </summary>
public class DeleteOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)] IFusionCache cache)
: IDeleteOrganizationIntegrationCommand
{
public async Task DeleteAsync(Guid organizationId, Guid integrationId)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
await integrationRepository.DeleteAsync(integration);
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Query implementation for retrieving organization integrations.
/// </summary>
public class GetOrganizationIntegrationsQuery(IOrganizationIntegrationRepository integrationRepository)
: IGetOrganizationIntegrationsQuery
{
public async Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId)
{
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
return integrations.ToList();
}
}

View File

@ -0,0 +1,18 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for creating an OrganizationIntegration.
/// </summary>
public interface ICreateOrganizationIntegrationCommand
{
/// <summary>
/// Creates a new organization integration.
/// </summary>
/// <param name="integration">The OrganizationIntegration to create.</param>
/// <returns>The created OrganizationIntegration.</returns>
/// <exception cref="Exceptions.BadRequestException">Thrown when an integration
/// of the same type already exists for the organization.</exception>
Task<OrganizationIntegration> CreateAsync(OrganizationIntegration integration);
}

View File

@ -0,0 +1,16 @@
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for deleting organization integrations.
/// </summary>
public interface IDeleteOrganizationIntegrationCommand
{
/// <summary>
/// Deletes an organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration to delete.</param>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist
/// or does not belong to the specified organization.</exception>
Task DeleteAsync(Guid organizationId, Guid integrationId);
}

View File

@ -0,0 +1,16 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Query interface for retrieving organization integrations.
/// </summary>
public interface IGetOrganizationIntegrationsQuery
{
/// <summary>
/// Retrieves all organization integrations for a specific organization.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <returns>A list of organization integrations associated with the organization.</returns>
Task<List<OrganizationIntegration>> GetManyByOrganizationAsync(Guid organizationId);
}

View File

@ -0,0 +1,20 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
/// <summary>
/// Command interface for updating organization integrations.
/// </summary>
public interface IUpdateOrganizationIntegrationCommand
{
/// <summary>
/// Updates an existing organization integration.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization.</param>
/// <param name="integrationId">The unique identifier of the integration to update.</param>
/// <param name="updatedIntegration">The updated organization integration data.</param>
/// <returns>The updated organization integration.</returns>
/// <exception cref="Exceptions.NotFoundException">Thrown when the integration does not exist,
/// does not belong to the specified organization, or the integration type does not match.</exception>
Task<OrganizationIntegration> UpdateAsync(Guid organizationId, Guid integrationId, OrganizationIntegration updatedIntegration);
}

View File

@ -0,0 +1,45 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.Extensions.DependencyInjection;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations;
/// <summary>
/// Command implementation for updating organization integrations with cache invalidation support.
/// </summary>
public class UpdateOrganizationIntegrationCommand(
IOrganizationIntegrationRepository integrationRepository,
[FromKeyedServices(EventIntegrationsCacheConstants.CacheName)]
IFusionCache cache)
: IUpdateOrganizationIntegrationCommand
{
public async Task<OrganizationIntegration> UpdateAsync(
Guid organizationId,
Guid integrationId,
OrganizationIntegration updatedIntegration)
{
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration is null ||
integration.OrganizationId != organizationId ||
integration.Type != updatedIntegration.Type)
{
throw new NotFoundException();
}
updatedIntegration.Id = integration.Id;
updatedIntegration.OrganizationId = integration.OrganizationId;
updatedIntegration.CreationDate = integration.CreationDate;
await integrationRepository.ReplaceAsync(updatedIntegration);
await cache.RemoveByTagAsync(
EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId: organizationId,
integrationType: integration.Type
));
return updatedIntegration;
}
}

View File

@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
bool UsePhishingBlocker { get; set; }
}

View File

@ -29,6 +29,7 @@ public class OrganizationAbility
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
UsePhishingBlocker = organization.UsePhishingBlocker;
}
public Guid Id { get; set; }
@ -51,4 +52,5 @@ public class OrganizationAbility
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
public bool UseAdminSponsoredFamilies { get; set; }
public bool? IsAdminInitiated { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization
Status = Status,
UseRiskInsights = UseRiskInsights,
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
UsePhishingBlocker = UsePhishingBlocker,
};
}
}

View File

@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
public bool UsePhishingBlocker { get; set; }
}

View File

@ -4,7 +4,6 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.Context;
@ -25,8 +24,6 @@ public class VerifyOrganizationDomainCommand(
IEventService eventService,
IGlobalSettings globalSettings,
ICurrentContext currentContext,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand,
IMailService mailService,
IOrganizationUserRepository organizationUserRepository,
@ -144,15 +141,8 @@ public class VerifyOrganizationDomainCommand(
PerformedBy = actingUser
};
if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser);
await vNextSavePolicyCommand.SaveAsync(savePolicyModel);
}
else
{
await savePolicyCommand.SaveAsync(policyUpdate);
}
var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser);
await vNextSavePolicyCommand.SaveAsync(savePolicyModel);
}
private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain)

View File

@ -0,0 +1,22 @@
# Automatic User Confirmation
Owned by: admin-console
Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model
for the workflow is as follows:
- The Api server sends an invite email to a user.
- The user accepts the invite request, which is sent back to the Api server
- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session.
- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server
- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB
This Feature has the following security measures in place in order to achieve our security goals:
- The single organization exemption for admins/owners is removed for this policy.
- This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users
- Emergency access is removed for all organization users
- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization)
- The organization has no members with the Provider user type.
- This will also prevent the policy and organization plan feature from being enabled
- This will prevent sending organization invites to provider users

View File

@ -0,0 +1,69 @@
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Utilities.DebuggingInstruments;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.Extensions.Logging;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public class BulkResendOrganizationInvitesCommand : IBulkResendOrganizationInvitesCommand
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly ISendOrganizationInvitesCommand _sendOrganizationInvitesCommand;
private readonly ILogger<BulkResendOrganizationInvitesCommand> _logger;
public BulkResendOrganizationInvitesCommand(
IOrganizationUserRepository organizationUserRepository,
IOrganizationRepository organizationRepository,
ISendOrganizationInvitesCommand sendOrganizationInvitesCommand,
ILogger<BulkResendOrganizationInvitesCommand> logger)
{
_organizationUserRepository = organizationUserRepository;
_organizationRepository = organizationRepository;
_sendOrganizationInvitesCommand = sendOrganizationInvitesCommand;
_logger = logger;
}
public async Task<IEnumerable<Tuple<OrganizationUser, string>>> BulkResendInvitesAsync(
Guid organizationId,
Guid? invitingUserId,
IEnumerable<Guid> organizationUsersId)
{
var orgUsers = await _organizationUserRepository.GetManyAsync(organizationUsersId);
_logger.LogUserInviteStateDiagnostics(orgUsers);
var org = await _organizationRepository.GetByIdAsync(organizationId);
if (org == null)
{
throw new NotFoundException();
}
var validUsers = new List<OrganizationUser>();
var result = new List<Tuple<OrganizationUser, string>>();
foreach (var orgUser in orgUsers)
{
if (orgUser.Status != OrganizationUserStatusType.Invited || orgUser.OrganizationId != organizationId)
{
result.Add(Tuple.Create(orgUser, "User invalid."));
}
else
{
validUsers.Add(orgUser);
}
}
if (validUsers.Any())
{
await _sendOrganizationInvitesCommand.SendInvitesAsync(
new SendInvitesRequest(validUsers, org));
result.AddRange(validUsers.Select(u => Tuple.Create(u, "")));
}
return result;
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
public interface IBulkResendOrganizationInvitesCommand
{
/// <summary>
/// Resend invites to multiple organization users in bulk.
/// </summary>
/// <param name="organizationId">The ID of the organization.</param>
/// <param name="invitingUserId">The ID of the user who is resending the invites.</param>
/// <param name="organizationUsersId">The IDs of the organization users to resend invites to.</param>
/// <returns>A tuple containing the OrganizationUser and an error message (empty string if successful)</returns>
Task<IEnumerable<Tuple<OrganizationUser, string>>> BulkResendInvitesAsync(
Guid organizationId,
Guid? invitingUserId,
IEnumerable<Guid> organizationUsersId);
}

View File

@ -1,7 +1,7 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
public interface IRevokeOrganizationUserCommand
{

View File

@ -7,7 +7,7 @@ using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
public class RevokeOrganizationUserCommand(
IEventService eventService,

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Utilities.v2;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
public record UserAlreadyRevoked() : BadRequestError("Already revoked.");
public record CannotRevokeYourself() : BadRequestError("You cannot revoke yourself.");
public record OnlyOwnersCanRevokeOwners() : BadRequestError("Only owners can revoke other owners.");
public record MustHaveConfirmedOwner() : BadRequestError("Organization must have at least one confirmed owner.");

View File

@ -0,0 +1,8 @@
using Bit.Core.AdminConsole.Utilities.v2.Results;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
public interface IRevokeOrganizationUserCommand
{
Task<IEnumerable<BulkCommandResult>> RevokeUsersAsync(RevokeOrganizationUsersRequest request);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
public interface IRevokeOrganizationUserValidator
{
Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(RevokeOrganizationUsersValidationRequest request);
}

View File

@ -0,0 +1,114 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Utilities.v2.Results;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using OneOf.Types;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
public class RevokeOrganizationUserCommand(
IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IPushNotificationService pushNotificationService,
IRevokeOrganizationUserValidator validator,
TimeProvider timeProvider,
ILogger<RevokeOrganizationUserCommand> logger)
: IRevokeOrganizationUserCommand
{
public async Task<IEnumerable<BulkCommandResult>> RevokeUsersAsync(RevokeOrganizationUsersRequest request)
{
var validationRequest = await CreateValidationRequestsAsync(request);
var results = await validator.ValidateAsync(validationRequest);
var validUsers = results.Where(r => r.IsValid).Select(r => r.Request).ToList();
await RevokeValidUsersAsync(validUsers);
await Task.WhenAll(
LogRevokedOrganizationUsersAsync(validUsers, request.PerformedBy),
SendPushNotificationsAsync(validUsers)
);
return results.Select(r => r.Match(
error => new BulkCommandResult(r.Request.Id, error),
_ => new BulkCommandResult(r.Request.Id, new None())
));
}
private async Task<RevokeOrganizationUsersValidationRequest> CreateValidationRequestsAsync(
RevokeOrganizationUsersRequest request)
{
var organizationUserToRevoke = await organizationUserRepository
.GetManyAsync(request.OrganizationUserIdsToRevoke);
return new RevokeOrganizationUsersValidationRequest(
request.OrganizationId,
request.OrganizationUserIdsToRevoke,
request.PerformedBy,
organizationUserToRevoke);
}
private async Task RevokeValidUsersAsync(ICollection<OrganizationUser> validUsers)
{
if (validUsers.Count == 0)
{
return;
}
await organizationUserRepository.RevokeManyByIdAsync(validUsers.Select(u => u.Id));
}
private async Task LogRevokedOrganizationUsersAsync(
ICollection<OrganizationUser> revokedUsers,
IActingUser actingUser)
{
if (revokedUsers.Count == 0)
{
return;
}
var eventDate = timeProvider.GetUtcNow().UtcDateTime;
if (actingUser is SystemUser { SystemUserType: not null })
{
var revokeEventsWithSystem = revokedUsers
.Select(user => (user, EventType.OrganizationUser_Revoked, actingUser.SystemUserType!.Value,
(DateTime?)eventDate))
.ToList();
await eventService.LogOrganizationUserEventsAsync(revokeEventsWithSystem);
}
else
{
var revokeEvents = revokedUsers
.Select(user => (user, EventType.OrganizationUser_Revoked, (DateTime?)eventDate))
.ToList();
await eventService.LogOrganizationUserEventsAsync(revokeEvents);
}
}
private async Task SendPushNotificationsAsync(ICollection<OrganizationUser> revokedUsers)
{
var userIdsToNotify = revokedUsers
.Where(user => user.UserId.HasValue)
.Select(user => user.UserId!.Value)
.Distinct()
.ToList();
foreach (var userId in userIdsToNotify)
{
try
{
await pushNotificationService.PushSyncOrgKeysAsync(userId);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to send push notification for user {UserId}.", userId);
}
}
}
}

View File

@ -0,0 +1,17 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
public record RevokeOrganizationUsersRequest(
Guid OrganizationId,
ICollection<Guid> OrganizationUserIdsToRevoke,
IActingUser PerformedBy
);
public record RevokeOrganizationUsersValidationRequest(
Guid OrganizationId,
ICollection<Guid> OrganizationUserIdsToRevoke,
IActingUser PerformedBy,
ICollection<OrganizationUser> OrganizationUsersToRevoke
) : RevokeOrganizationUsersRequest(OrganizationId, OrganizationUserIdsToRevoke, PerformedBy);

View File

@ -0,0 +1,39 @@
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Utilities.v2.Validation;
using Bit.Core.Entities;
using Bit.Core.Enums;
using static Bit.Core.AdminConsole.Utilities.v2.Validation.ValidationResultHelpers;
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
public class RevokeOrganizationUsersValidator(IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery)
: IRevokeOrganizationUserValidator
{
public async Task<ICollection<ValidationResult<OrganizationUser>>> ValidateAsync(
RevokeOrganizationUsersValidationRequest request)
{
var hasRemainingOwner = await hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(request.OrganizationId,
request.OrganizationUsersToRevoke.Select(x => x.Id) // users excluded because they are going to be revoked
);
return request.OrganizationUsersToRevoke.Select(x =>
{
return x switch
{
_ when request.PerformedBy is not SystemUser
&& x.UserId is not null
&& x.UserId == request.PerformedBy.UserId =>
Invalid(x, new CannotRevokeYourself()),
{ Status: OrganizationUserStatusType.Revoked } =>
Invalid(x, new UserAlreadyRevoked()),
{ Type: OrganizationUserType.Owner } when !hasRemainingOwner =>
Invalid(x, new MustHaveConfirmedOwner()),
{ Type: OrganizationUserType.Owner } when !request.PerformedBy.IsOrganizationOwnerOrProvider =>
Invalid(x, new OnlyOwnersCanRevokeOwners()),
_ => Valid(x)
};
}).ToList();
}
}

View File

@ -0,0 +1,15 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
public interface IOrganizationUpdateCommand
{
/// <summary>
/// Updates an organization's information in the Bitwarden database and Stripe (if required).
/// Also optionally updates an organization's public-private keypair if it was not created with one.
/// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file.
/// </summary>
/// <param name="request">The update request containing the details to be updated.</param>
Task<Organization> UpdateAsync(OrganizationUpdateRequest request);
}

View File

@ -0,0 +1,77 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
public class OrganizationUpdateCommand(
IOrganizationService organizationService,
IOrganizationRepository organizationRepository,
IGlobalSettings globalSettings,
IOrganizationBillingService organizationBillingService
) : IOrganizationUpdateCommand
{
public async Task<Organization> UpdateAsync(OrganizationUpdateRequest request)
{
var organization = await organizationRepository.GetByIdAsync(request.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
if (globalSettings.SelfHosted)
{
return await UpdateSelfHostedAsync(organization, request);
}
return await UpdateCloudAsync(organization, request);
}
private async Task<Organization> UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request)
{
// Store original values for comparison
var originalName = organization.Name;
var originalBillingEmail = organization.BillingEmail;
// Apply updates to organization
organization.UpdateDetails(request);
organization.BackfillPublicPrivateKeys(request);
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
// Update billing information in Stripe if required
await UpdateBillingAsync(organization, originalName, originalBillingEmail);
return organization;
}
/// <summary>
/// Self-host cannot update the organization details because they are set by the license file.
/// However, this command does offer a soft migration pathway for organizations without public and private keys.
/// If we remove this migration code in the future, this command and endpoint can become cloud only.
/// </summary>
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
{
organization.BackfillPublicPrivateKeys(request);
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
return organization;
}
private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail)
{
// Update Stripe if name or billing email changed
var shouldUpdateBilling = originalName != organization.Name ||
originalBillingEmail != organization.BillingEmail;
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
{
return;
}
await organizationBillingService.UpdateOrganizationNameAndEmail(organization);
}
}

View File

@ -0,0 +1,43 @@
using Bit.Core.AdminConsole.Entities;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
public static class OrganizationUpdateExtensions
{
/// <summary>
/// Updates the organization name and/or billing email.
/// Any null property on the request object will be skipped.
/// </summary>
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
{
// These values may or may not be sent by the client depending on the operation being performed.
// Skip any values not provided.
if (request.Name is not null)
{
organization.Name = request.Name;
}
if (request.BillingEmail is not null)
{
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
}
}
/// <summary>
/// Updates the organization public and private keys if provided and not already set.
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
/// migration that will silently migrate organizations when they change their details.
/// </summary>
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
{
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
{
organization.PublicKey = request.PublicKey;
}
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
{
organization.PrivateKey = request.EncryptedPrivateKey;
}
}
}

View File

@ -0,0 +1,33 @@
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
/// <summary>
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything.
/// </summary>
public record OrganizationUpdateRequest
{
/// <summary>
/// The ID of the organization to update.
/// </summary>
public required Guid OrganizationId { get; init; }
/// <summary>
/// The new organization name to apply (optional, this is skipped if not provided).
/// </summary>
public string? Name { get; init; }
/// <summary>
/// The new billing email address to apply (optional, this is skipped if not provided).
/// </summary>
public string? BillingEmail { get; init; }
/// <summary>
/// The organization's public key to set (optional, only set if not already present on the organization).
/// </summary>
public string? PublicKey { get; init; }
/// <summary>
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
/// </summary>
public string? EncryptedPrivateKey { get; init; }
}

View File

@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand
private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators;
private readonly TimeProvider _timeProvider;
private readonly IPostSavePolicySideEffect _postSavePolicySideEffect;
private readonly IPushNotificationService _pushNotificationService;
public SavePolicyCommand(IApplicationCacheService applicationCacheService,
IEventService eventService,
IPolicyRepository policyRepository,
IEnumerable<IPolicyValidator> policyValidators,
TimeProvider timeProvider,
IPostSavePolicySideEffect postSavePolicySideEffect)
IPostSavePolicySideEffect postSavePolicySideEffect,
IPushNotificationService pushNotificationService)
{
_applicationCacheService = applicationCacheService;
_eventService = eventService;
_policyRepository = policyRepository;
_timeProvider = timeProvider;
_postSavePolicySideEffect = postSavePolicySideEffect;
_pushNotificationService = pushNotificationService;
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
foreach (var policyValidator in policyValidators)
@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand
await _policyRepository.UpsertAsync(policy);
await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated);
await PushPolicyUpdateToClients(policy.OrganizationId, policy);
return policy;
}
@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type);
return (savedPoliciesDict, currentPolicy);
}
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
{
Type = PushType.PolicyChanged,
Target = NotificationTarget.Organization,
TargetId = organizationId,
ExcludeCurrentContext = false,
Payload = new SyncPolicyPushNotification
{
Policy = policy,
OrganizationId = organizationId
}
});
}

View File

@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models;
using Bit.Core.Platform.Push;
using Bit.Core.Services;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
@ -15,7 +17,8 @@ public class VNextSavePolicyCommand(
IPolicyRepository policyRepository,
IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
TimeProvider timeProvider,
IPolicyEventHandlerFactory policyEventHandlerFactory)
IPolicyEventHandlerFactory policyEventHandlerFactory,
IPushNotificationService pushNotificationService)
: IVNextSavePolicyCommand
{
@ -74,7 +77,7 @@ public class VNextSavePolicyCommand(
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await policyRepository.UpsertAsync(policy);
await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy);
return policy;
}
@ -192,4 +195,17 @@ public class VNextSavePolicyCommand(
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
return savedPoliciesDict;
}
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
{
Type = PushType.PolicyChanged,
Target = NotificationTarget.Organization,
TargetId = organizationId,
ExcludeCurrentContext = false,
Payload = new SyncPolicyPushNotification
{
Policy = policy,
OrganizationId = organizationId
}
});
}

View File

@ -35,6 +35,8 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyValidator, BlockClaimedDomainAccountCreationPolicyValidator>();
services.AddScoped<IPolicyValidator, AutomaticUserConfirmationPolicyEventHandler>();
}
[Obsolete("Use AddPolicyUpdateEvents instead.")]

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
@ -17,26 +18,13 @@ namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
/// <li>All organization users are compliant with the Single organization policy</li>
/// <li>No provider users exist</li>
/// </ul>
///
/// This class also performs side effects when the policy is being enabled or disabled. They are:
/// <ul>
/// <li>Sets the UseAutomaticUserConfirmation organization feature to match the policy update</li>
/// </ul>
/// </summary>
public class AutomaticUserConfirmationPolicyEventHandler(
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
IPolicyRepository policyRepository,
IOrganizationRepository organizationRepository,
TimeProvider timeProvider)
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
IProviderUserRepository providerUserRepository)
: IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
public async Task ExecutePreUpsertSideEffectAsync(SavePolicyModel policyRequest, Policy? currentPolicy) =>
await OnSaveSideEffectsAsync(policyRequest.PolicyUpdate, currentPolicy);
private const string _singleOrgPolicyNotEnabledErrorMessage =
"The Single organization policy must be enabled before enabling the Automatically confirm invited users policy.";
private const string _usersNotCompliantWithSingleOrgErrorMessage =
"All organization users must be compliant with the Single organization policy before enabling the Automatically confirm invited users policy. Please remove users who are members of multiple organizations.";
@ -61,27 +49,20 @@ public class AutomaticUserConfirmationPolicyEventHandler(
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
{
var organization = await organizationRepository.GetByIdAsync(policyUpdate.OrganizationId);
if (organization is not null)
{
organization.UseAutomaticUserConfirmation = policyUpdate.Enabled;
organization.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
await organizationRepository.UpsertAsync(organization);
}
}
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) =>
Task.CompletedTask;
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
{
var singleOrgValidationError = await ValidateSingleOrgPolicyComplianceAsync(organizationId);
var organizationUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var singleOrgValidationError = await ValidateUserComplianceWithSingleOrgAsync(organizationId, organizationUsers);
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
{
return singleOrgValidationError;
}
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers);
if (!string.IsNullOrWhiteSpace(providerValidationError))
{
return providerValidationError;
@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler(
return string.Empty;
}
private async Task<string> ValidateSingleOrgPolicyComplianceAsync(Guid organizationId)
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId,
ICollection<OrganizationUserUserDetails> organizationUsers)
{
var singleOrgPolicy = await policyRepository.GetByOrganizationIdTypeAsync(organizationId, PolicyType.SingleOrg);
if (singleOrgPolicy is not { Enabled: true })
{
return _singleOrgPolicyNotEnabledErrorMessage;
}
return await ValidateUserComplianceWithSingleOrgAsync(organizationId);
}
private async Task<string> ValidateUserComplianceWithSingleOrgAsync(Guid organizationId)
{
var organizationUsers = (await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId))
.Where(ou => ou.Status != OrganizationUserStatusType.Invited &&
ou.Status != OrganizationUserStatusType.Revoked &&
ou.UserId.HasValue)
.ToList();
if (organizationUsers.Count == 0)
{
return string.Empty;
}
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
organizationUsers.Select(ou => ou.UserId!.Value)))
.Any(uo => uo.OrganizationId != organizationId &&
uo.Status != OrganizationUserStatusType.Invited);
.Any(uo => uo.OrganizationId != organizationId
&& uo.Status != OrganizationUserStatusType.Invited);
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
}
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
private async Task<string> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> organizationUsers)
{
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
var userIds = organizationUsers.Where(x => x.UserId is not null)
.Select(x => x.UserId!.Value);
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0
? _providerUsersExistErrorMessage
: string.Empty;
}
}

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