Merge branch 'main' into auth/pm-22975/client-version-validator

This commit is contained in:
Patrick Pimentel 2025-12-08 13:30:14 -05:00
commit 27c9e4d5da
No known key found for this signature in database
GPG Key ID: 4B27FC74C6422186
156 changed files with 7890 additions and 1556 deletions

View File

@ -44,6 +44,7 @@
{
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
dependencyDashboardApproval: true
},
{
matchManagers: ["dockerfile", "docker-compose"],

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
}

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

@ -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

@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
public class OrganizationUserBulkRequestModel
{
[Required]
[Required, MinLength(1)]
public IEnumerable<Guid> Ids { 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;
@ -177,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

@ -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

@ -80,7 +80,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

@ -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

@ -11,6 +11,7 @@ using Bit.Core.Billing.Pricing;
using Bit.Core.Entities;
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;
@ -606,14 +607,27 @@ public class UpcomingInvoiceHandler(
User user,
PremiumPlan premiumPlan)
{
/* TODO: Replace with proper premium renewal email template once finalized.
Using Families2020RenewalMail as a temporary stop-gap. */
var email = new Families2020RenewalMail
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 Families2020RenewalMailView
View = new PremiumRenewalMailView
{
MonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US"))
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
DiscountAmount = $"{coupon.PercentOff}%",
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
}
};

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

@ -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,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

@ -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;
}
}

View File

@ -6,10 +6,23 @@ namespace Bit.Core.Repositories;
public interface IOrganizationIntegrationConfigurationRepository : IRepository<OrganizationIntegrationConfiguration, Guid>
{
Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
/// <summary>
/// Retrieve the list of available configuration details for a specific event for the organization and
/// integration type.<br/>
/// <br/>
/// <b>Note:</b> This returns all configurations that match the event type explicitly <b>and</b>
/// all the configurations that have a null event type - null event type is considered a
/// wildcard that matches all events.
///
/// </summary>
/// <param name="eventType">The specific event type</param>
/// <param name="organizationId">The id of the organization</param>
/// <param name="integrationType">The integration type</param>
/// <returns>A List of <see cref="OrganizationIntegrationConfigurationDetails"/> that match</returns>
Task<List<OrganizationIntegrationConfigurationDetails>> GetManyByEventTypeOrganizationIdIntegrationType(
EventType eventType,
Guid organizationId,
IntegrationType integrationType,
EventType eventType);
IntegrationType integrationType);
Task<List<OrganizationIntegrationConfigurationDetails>> GetAllConfigurationDetailsAsync();

View File

@ -12,6 +12,7 @@ public interface IProviderUserRepository : IRepository<ProviderUser, Guid>
Task<int> GetCountByProviderAsync(Guid providerId, string email, bool onlyRegisteredUsers);
Task<ICollection<ProviderUser>> GetManyAsync(IEnumerable<Guid> ids);
Task<ICollection<ProviderUser>> GetManyByUserAsync(Guid userId);
Task<ICollection<ProviderUser>> GetManyByManyUsersAsync(IEnumerable<Guid> userIds);
Task<ProviderUser?> GetByProviderUserAsync(Guid providerId, Guid userId);
Task<ICollection<ProviderUser>> GetManyByProviderAsync(Guid providerId, ProviderUserType? type = null);
Task<ICollection<ProviderUserUserDetails>> GetManyDetailsByProviderAsync(Guid providerId, ProviderUserStatusType? status = null);

View File

@ -5,6 +5,7 @@ using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
@ -17,8 +18,8 @@ public class EventIntegrationHandler<T>(
IntegrationType integrationType,
IEventIntegrationPublisher eventIntegrationPublisher,
IIntegrationFilterService integrationFilterService,
IIntegrationConfigurationDetailsCache configurationCache,
IFusionCache cache,
IOrganizationIntegrationConfigurationRepository configurationRepository,
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
@ -27,17 +28,7 @@ public class EventIntegrationHandler<T>(
{
public async Task HandleEventAsync(EventMessage eventMessage)
{
if (eventMessage.OrganizationId is not Guid organizationId)
{
return;
}
var configurations = configurationCache.GetConfigurationDetails(
organizationId,
integrationType,
eventMessage.Type);
foreach (var configuration in configurations)
foreach (var configuration in await GetConfigurationDetailsListAsync(eventMessage))
{
try
{
@ -64,7 +55,7 @@ public class EventIntegrationHandler<T>(
{
IntegrationType = integrationType,
MessageId = messageId.ToString(),
OrganizationId = organizationId.ToString(),
OrganizationId = eventMessage.OrganizationId?.ToString(),
Configuration = config,
RenderedTemplate = renderedTemplate,
RetryCount = 0,
@ -132,6 +123,37 @@ public class EventIntegrationHandler<T>(
return context;
}
private async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsListAsync(EventMessage eventMessage)
{
if (eventMessage.OrganizationId is not Guid organizationId)
{
return [];
}
List<OrganizationIntegrationConfigurationDetails> configurations = [];
var integrationTag = EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration(
organizationId,
integrationType
);
configurations.AddRange(await cache.GetOrSetAsync<List<OrganizationIntegrationConfigurationDetails>>(
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
organizationId: organizationId,
integrationType: integrationType,
eventType: eventMessage.Type),
factory: async _ => await configurationRepository.GetManyByEventTypeOrganizationIdIntegrationType(
eventType: eventMessage.Type,
organizationId: organizationId,
integrationType: integrationType),
options: new FusionCacheEntryOptions(
duration: EventIntegrationsCacheConstants.DurationForOrganizationIntegrationConfigurationDetails),
tags: [integrationTag]
));
return configurations;
}
private async Task<OrganizationUserUserDetails?> GetUserFromCacheAsync(Guid organizationId, Guid userId) =>
await cache.GetOrSetAsync<OrganizationUserUserDetails?>(
key: EventIntegrationsCacheConstants.BuildCacheKeyForOrganizationUser(organizationId, userId),

View File

@ -1,83 +0,0 @@
using System.Diagnostics;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Bit.Core.Services;
public class IntegrationConfigurationDetailsCacheService : BackgroundService, IIntegrationConfigurationDetailsCache
{
private readonly record struct IntegrationCacheKey(Guid OrganizationId, IntegrationType IntegrationType, EventType? EventType);
private readonly IOrganizationIntegrationConfigurationRepository _repository;
private readonly ILogger<IntegrationConfigurationDetailsCacheService> _logger;
private readonly TimeSpan _refreshInterval;
private Dictionary<IntegrationCacheKey, List<OrganizationIntegrationConfigurationDetails>> _cache = new();
public IntegrationConfigurationDetailsCacheService(
IOrganizationIntegrationConfigurationRepository repository,
GlobalSettings globalSettings,
ILogger<IntegrationConfigurationDetailsCacheService> logger)
{
_repository = repository;
_logger = logger;
_refreshInterval = TimeSpan.FromMinutes(globalSettings.EventLogging.IntegrationCacheRefreshIntervalMinutes);
}
public List<OrganizationIntegrationConfigurationDetails> GetConfigurationDetails(
Guid organizationId,
IntegrationType integrationType,
EventType eventType)
{
var specificKey = new IntegrationCacheKey(organizationId, integrationType, eventType);
var allEventsKey = new IntegrationCacheKey(organizationId, integrationType, null);
var results = new List<OrganizationIntegrationConfigurationDetails>();
if (_cache.TryGetValue(specificKey, out var specificConfigs))
{
results.AddRange(specificConfigs);
}
if (_cache.TryGetValue(allEventsKey, out var fallbackConfigs))
{
results.AddRange(fallbackConfigs);
}
return results;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await RefreshAsync();
var timer = new PeriodicTimer(_refreshInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await RefreshAsync();
}
}
internal async Task RefreshAsync()
{
var stopwatch = Stopwatch.StartNew();
try
{
var newCache = (await _repository.GetAllConfigurationDetailsAsync())
.GroupBy(x => new IntegrationCacheKey(x.OrganizationId, x.IntegrationType, x.EventType))
.ToDictionary(g => g.Key, g => g.ToList());
_cache = newCache;
stopwatch.Stop();
_logger.LogInformation(
"[IntegrationConfigurationDetailsCacheService] Refreshed successfully: {Count} entries in {Duration}ms",
newCache.Count,
stopwatch.Elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogError("[IntegrationConfigurationDetailsCacheService] Refresh failed: {ex}", ex);
}
}
}

View File

@ -295,33 +295,60 @@ graph TD
```
## Caching
To reduce database load and improve performance, integration configurations are cached in-memory as a Dictionary
with a periodic load of all configurations. Without caching, each incoming `EventMessage` would trigger a database
To reduce database load and improve performance, event integrations uses its own named extended cache (see
[CACHING in Utilities](https://github.com/bitwarden/server/blob/main/src/Core/Utilities/CACHING.md)
for more information). Without caching, for instance, each incoming `EventMessage` would trigger a database
query to retrieve the relevant `OrganizationIntegrationConfigurationDetails`.
By loading all configurations into memory on a fixed interval, we ensure:
### `EventIntegrationsCacheConstants`
- Consistent performance for reads.
- Reduced database pressure.
- Predictable refresh timing, independent of event activity.
`EventIntegrationsCacheConstants` allows the code to have strongly typed references to a number of cache-related
details when working with the extended cache. The cache name and all cache keys and tags are programmatically accessed
from `EventIntegrationsCacheConstants` rather than simple strings. For instance,
`EventIntegrationsCacheConstants.CacheName` is used in the cache setup, keyed services, dependency injection, etc.,
rather than using a string literal (i.e. "EventIntegrations") in code.
### Architecture / Design
### `OrganizationIntegrationConfigurationDetails`
- The cache is read-only for consumers. It is only updated in bulk by a background refresh process.
- The cache is fully replaced on each refresh to avoid locking or partial state.
- This is one of the most actively used portions of the architecture because any event that has an associated
organization requires a check of the configurations to determine if we need to fire off an integration.
- By using the extended cache, all reads are hitting the L1 or L2 cache before needing to access the database.
- Reads return a `List<OrganizationIntegrationConfigurationDetails>` for a given key or an empty list if no
match exists.
- Failures or delays in the loading process do not affect the existing cache state. The cache will continue serving
the last known good state until the update replaces the whole cache.
- The TTL is set very high on these records (1 day). This is because when the admin API makes any changes, it
tells the cache to remove that key. This propagates to the event listening code via the extended cache backplane,
which means that the cache is then expired and the next read will fetch the new values. This allows us to have
a high TTL and avoid needing to refresh values except when necessary.
### Background Refresh
#### Tagging per integration
A hosted service (`IntegrationConfigurationDetailsCacheService`) runs in the background and:
- Each entry in the cache (which again, returns `List<OrganizationIntegrationConfigurationDetails>`) is tagged with
the organization id and the integration type.
- This allows us to remove all of a given organization's configuration details for an integration when the admin
makes changes at the integration level.
- For instance, if there were 5 events configured for a given organization's webhook and the admin changed the URL
at the integration level, the updates would need to be propagated or else the cache will continue returning the
stale URL.
- By tagging each of the entries, the API can ask the extended cache to remove all the entries for a given
organization integration in one call. The cache will handle dropping / refreshing these entries in a
performant way.
- There are two places in the code that are both aware of the tagging functionality
- The `EventIntegrationHandler` must use the tag when fetching relevant configuration details. This tells the cache
to store the entry with the tag when it successfully loads from the repository.
- The `CreateOrganizationIntegrationCommand`, `UpdateOrganizationIntegrationCommand`, and
`DeleteOrganizationIntegrationCommand` commands need to use the tag to remove all the tagged entries when an admin
creates, updates, or deletes an integration.
- To ensure both places are synchronized on how to tag entries, they both use
`EventIntegrationsCacheConstants.BuildCacheTagForOrganizationIntegration` to build the tag.
- Loads all configuration records at application startup.
- Refreshes the cache on a configurable interval.
- Logs timing and entry count on success.
- Logs exceptions on failure without disrupting application flow.
### Template Properties
- The `IntegrationTemplateProcessor` supports some properties that require an additional lookup. For instance,
the `UserId` is provided as part of the `EventMessage`, but `UserName` means an additional lookup to map the user
id to the actual name.
- The properties for a `User` (which includes `ActingUser`), `Group`, and `Organization` are cached via the
extended cache with a default TTL of 30 minutes.
- This is cached in both the L1 (Memory) and L2 (Redis) and will be automatically refreshed as needed.
# Building a new integration

View File

@ -5,7 +5,6 @@ using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
using Bit.Core.AdminConsole.Repositories;
@ -26,8 +25,6 @@ public class SsoConfigService : ISsoConfigService
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IEventService _eventService;
private readonly IFeatureService _featureService;
private readonly ISavePolicyCommand _savePolicyCommand;
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
public SsoConfigService(
@ -36,8 +33,6 @@ public class SsoConfigService : ISsoConfigService
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IEventService eventService,
IFeatureService featureService,
ISavePolicyCommand savePolicyCommand,
IVNextSavePolicyCommand vNextSavePolicyCommand)
{
_ssoConfigRepository = ssoConfigRepository;
@ -45,8 +40,6 @@ public class SsoConfigService : ISsoConfigService
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_eventService = eventService;
_featureService = featureService;
_savePolicyCommand = savePolicyCommand;
_vNextSavePolicyCommand = vNextSavePolicyCommand;
}
@ -97,19 +90,10 @@ public class SsoConfigService : ISsoConfigService
Enabled = true
};
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
{
var performedBy = new SystemUser(EventSystemUser.Unknown);
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy));
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy));
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy));
}
else
{
await _savePolicyCommand.SaveAsync(singleOrgPolicy);
await _savePolicyCommand.SaveAsync(resetPasswordPolicy);
await _savePolicyCommand.SaveAsync(requireSsoPolicy);
}
var performedBy = new SystemUser(EventSystemUser.Unknown);
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(singleOrgPolicy, performedBy));
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(resetPasswordPolicy, performedBy));
await _vNextSavePolicyCommand.SaveAsync(new SavePolicyModel(requireSsoPolicy, performedBy));
}
await LogEventsAsync(config, oldConfig);

View File

@ -5,6 +5,7 @@ using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@ -455,9 +456,7 @@ public class RegisterUserCommand : IRegisterUserCommand
else if (!string.IsNullOrEmpty(organization.DisplayName()))
{
// If the organization is Free or Families plan, send families welcome email
if (organization.PlanType is PlanType.FamiliesAnnually
or PlanType.FamiliesAnnually2019
or PlanType.Free)
if (organization.PlanType.GetProductTier() is ProductTierType.Free or ProductTierType.Families)
{
await _mailService.SendFreeOrgOrFamilyOrgUserWelcomeEmailAsync(user, organization.DisplayName());
}

View File

@ -12,6 +12,12 @@ public static class StripeConstants
public const string UnrecognizedLocation = "unrecognized_location";
}
public static class BillingReasons
{
public const string SubscriptionCreate = "subscription_create";
public const string SubscriptionCycle = "subscription_cycle";
}
public static class CollectionMethod
{
public const string ChargeAutomatically = "charge_automatically";

View File

@ -140,10 +140,9 @@ public static class FeatureFlagKeys
public const string CreateDefaultLocation = "pm-19467-create-default-location";
public const string AutomaticConfirmUsers = "pm-19934-auto-confirm-organization-users";
public const string PM23845_VNextApplicationCache = "pm-24957-refactor-memory-application-cache";
public const string AccountRecoveryCommand = "pm-25581-prevent-provider-account-recovery";
public const string BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration";
public const string PolicyValidatorsRefactor = "pm-26423-refactor-policy-side-effects";
public const string IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud";
public const string BulkRevokeUsersV2 = "pm-28456-bulk-revoke-users-v2";
/* Architecture */
public const string DesktopMigrationMilestone1 = "desktop-ui-migration-milestone-1";
@ -214,6 +213,7 @@ public static class FeatureFlagKeys
public const string NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change";
public const string DisableType0Decryption = "pm-25174-disable-type-0-decryption";
public const string ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component";
public const string DataRecoveryTool = "pm-28813-data-recovery-tool";
/* Mobile Team */
public const string AndroidImportLoginsFlow = "import-logins-flow";
@ -243,15 +243,14 @@ public static class FeatureFlagKeys
/* Vault Team */
public const string CipherKeyEncryption = "cipher-key-encryption";
public const string PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk";
public const string EndUserNotifications = "pm-10609-end-user-notifications";
public const string PhishingDetection = "phishing-detection";
public const string RemoveCardItemTypePolicy = "pm-16442-remove-card-item-type-policy";
public const string PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view";
public const string PM19315EndUserActivationMvp = "pm-19315-end-user-activation-mvp";
public const string PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption";
public const string PM23904_RiskInsightsForPremium = "pm-23904-risk-insights-for-premium";
public const string PM25083_AutofillConfirmFromSearch = "pm-25083-autofill-confirm-from-search";
public const string VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders";
public const string BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight";
/* Innovation Team */
public const string ArchiveVaultItems = "pm-19148-innovation-archive";

View File

@ -23,8 +23,8 @@
<ItemGroup>
<PackageReference Include="AspNetCoreRateLimit.Redis" Version="2.0.0" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.1.3" />
<PackageReference Include="AWSSDK.SQS" Version="4.0.1.5" />
<PackageReference Include="AWSSDK.SimpleEmail" Version="4.0.2.5" />
<PackageReference Include="AWSSDK.SQS" Version="4.0.2.5" />
<PackageReference Include="Azure.Data.Tables" Version="12.11.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.DataProtection.Blobs" Version="1.3.4" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.10" />
@ -54,7 +54,7 @@
<PackageReference Include="Duende.IdentityServer" Version="7.2.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" />
<PackageReference Include="Braintree" Version="5.28.0" />
<PackageReference Include="Braintree" Version="5.36.0" />
<PackageReference Include="Stripe.net" Version="48.5.0" />
<PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="YubicoDotNetClient" Version="1.2.0" />

View File

@ -1,8 +1,7 @@
using Bit.Core.KeyManagement.Models.Api.Request;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
namespace Bit.Core.KeyManagement.Models.Api.Request;
public class AccountKeysRequestModel
{

View File

@ -1,7 +1,7 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
namespace Bit.Core.KeyManagement.Models.Api.Request;
public class PublicKeyEncryptionKeyPairRequestModel
{

View File

@ -1,7 +1,7 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.KeyManagement.Models.Requests;
namespace Bit.Core.KeyManagement.Models.Api.Request;
public class SignatureKeyPairRequestModel
{

View File

@ -53,11 +53,37 @@
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
@media only screen and (max-width:480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<style type="text/css">
@ -67,29 +93,8 @@
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
@media only screen and (max-width: 480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<!-- Responsive icon visibility -->
<!-- Responsive styling for mj-bw-icon-row -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
@ -156,7 +161,7 @@
</h1>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
Let's get set up to autofill.
Lets get you set up to autofill.
</h2>
</mj-text></div>
@ -176,7 +181,7 @@
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
@ -256,7 +261,7 @@
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">A <b>{{OrganizationName}}</b> administrator will approve you
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">An administrator from <b>{{OrganizationName}}</b> will approve you
before you can share passwords. While you wait for approval, get
started with Bitwarden Password Manager:</div>
@ -622,10 +627,10 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
@ -643,7 +648,7 @@
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>

View File

@ -53,11 +53,37 @@
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
@media only screen and (max-width:480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<style type="text/css">
@ -67,29 +93,8 @@
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
@media only screen and (max-width: 480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<!-- Responsive icon visibility -->
<!-- Responsive styling for mj-bw-icon-row -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
@ -156,7 +161,7 @@
</h1>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
Let's get set up to autofill.
Lets get you set up to autofill.
</h2>
</mj-text></div>
@ -176,7 +181,7 @@
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
@ -621,10 +626,10 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
@ -642,7 +647,7 @@
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>

View File

@ -53,11 +53,37 @@
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-hero-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
@media only screen and (max-width:480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<style type="text/css">
@ -67,29 +93,8 @@
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
@media only screen and (max-width: 480px) {
.hide-small-img {
display: none !important;
}
.send-bubble {
padding-left: 20px;
padding-right: 20px;
width: 90% !important;
}
}
@media only screen and (max-width: 480px) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
</style>
<!-- Responsive icon visibility -->
<!-- Responsive styling for mj-bw-icon-row -->
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
@ -156,7 +161,7 @@
</h1>
<mj-text color="#fff" padding-top="0" padding-bottom="0">
<h2 style="font-weight: normal; font-size: 16px; line-height: 0px">
Let's get set up to autofill.
Lets get you set up to autofill.
</h2>
</mj-text></div>
@ -176,7 +181,7 @@
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:0px;word-break:break-word;">
<td align="center" class="mj-bw-hero-responsive-img" style="font-size:0px;padding:0px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
@ -256,7 +261,7 @@
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">A <b>{{OrganizationName}}</b> administrator will need to confirm
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">An administrator from <b>{{OrganizationName}}</b> will need to confirm
you before you can share passwords. Get started with Bitwarden
Password Manager:</div>
@ -622,10 +627,10 @@
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
@ -643,7 +648,7 @@
<tbody>
<tr>
<td align="center" class="hide-small-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>

View File

@ -41,8 +41,10 @@ if (!fs.existsSync(config.outputDir)) {
}
}
// Find all MJML files with absolute path
const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`);
// Find all MJML files with absolute paths, excluding components directories
const mjmlFiles = glob.sync(`${config.inputDir}/**/*.mjml`, {
ignore: ['**/components/**']
});
console.log(`\n[INFO] Found ${mjmlFiles.length} MJML file(s) to compile...`);

View File

@ -18,16 +18,16 @@ class MjBwIconRow extends BodyComponent {
static defaultAttributes = {};
componentHeadStyle = (breakpoint) => {
headStyle = (breakpoint) => {
return `
@media only screen and (max-width:${breakpoint}): {
".mj-bw-icon-row-text": {
padding-left: "5px !important",
line-height: "20px",
},
".mj-bw-icon-row": {
padding: "10px 15px",
width: "fit-content !important",
@media only screen and (max-width:${breakpoint}) {
.mj-bw-icon-row-text {
padding-left: 5px !important;
line-height: 20px;
}
.mj-bw-icon-row {
padding: 10px 15px;
width: fit-content !important;
}
}
`;

View File

@ -9,7 +9,7 @@
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
title="Welcome to Bitwarden!"
sub-title="Let's get set up to autofill."
sub-title="Lets get you set up to autofill."
/>
</mj-wrapper>

View File

@ -9,7 +9,7 @@
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
title="Welcome to Bitwarden!"
sub-title="Let's get set up to autofill."
sub-title="Lets get you set up to autofill."
/>
</mj-wrapper>

View File

@ -9,7 +9,7 @@
<mj-bw-hero
img-src="https://assets.bitwarden.com/email/v1/account-fill.png"
title="Welcome to Bitwarden!"
sub-title="Let's get set up to autofill."
sub-title="Lets get you set up to autofill."
/>
</mj-wrapper>

View File

@ -0,0 +1,41 @@
<mjml>
<mj-head>
<mj-include path="../../../components/head.mjml"/>
</mj-head>
<!-- Blue Header Section-->
<mj-body css-class="border-fix">
<mj-wrapper css-class="border-fix" padding="20px 20px 0px 20px">
<mj-bw-simple-hero />
</mj-wrapper>
<!-- Main Content Section -->
<mj-wrapper padding="0px 20px 0px 20px">
<mj-section background-color="#fff" padding="15px 10px 10px 10px">
<mj-column>
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px 15px 15px">
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
</mj-text>
<mj-text font-size="16px" line-height="24px" padding="10px 15px">
Questions? Contact
<a href="mailto:support@bitwarden.com" class="link">support@bitwarden.com</a>
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fff" padding="0 20px 20px 20px">
</mj-section>
</mj-wrapper>
<!-- Learn More Section -->
<mj-wrapper padding="0px 20px 10px 20px">
<mj-bw-learn-more-footer/>
</mj-wrapper>
<!-- Footer -->
<mj-include path="../../../components/footer.mjml"/>
</mj-body>
</mjml>

View File

@ -0,0 +1,15 @@
using Bit.Core.Platform.Mail.Mailer;
namespace Bit.Core.Models.Mail.Billing.Renewal.Premium;
public class PremiumRenewalMailView : BaseMailView
{
public required string BaseMonthlyRenewalPrice { get; set; }
public required string DiscountedMonthlyRenewalPrice { get; set; }
public required string DiscountAmount { get; set; }
}
public class PremiumRenewalMail : BaseMail<PremiumRenewalMailView>
{
public override string Subject { get => "Your Bitwarden Premium renewal is updating"; }
}

View File

@ -0,0 +1,583 @@
<!doctype html>
<html lang="und" dir="auto" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<title></title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
#outlook a { padding:0; }
body { margin:0;padding:0;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%; }
table, td { border-collapse:collapse;mso-table-lspace:0pt;mso-table-rspace:0pt; }
img { border:0;height:auto;line-height:100%; outline:none;text-decoration:none;-ms-interpolation-mode:bicubic; }
p { display:block;margin:13px 0; }
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix { width:100% !important; }
</style>
<![endif]-->
<style type="text/css">
@media only screen and (min-width:480px) {
.mj-column-per-100 { width:100% !important; max-width: 100%; }
.mj-column-per-70 { width:70% !important; max-width: 70%; }
.mj-column-per-30 { width:30% !important; max-width: 30%; }
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 { width:100% !important; max-width: 100%; }
.moz-text-html .mj-column-per-70 { width:70% !important; max-width: 70%; }
.moz-text-html .mj-column-per-30 { width:30% !important; max-width: 30%; }
</style>
<style type="text/css">
@media only screen and (max-width:480px) {
.mj-bw-learn-more-footer-responsive-img {
display: none !important;
}
}
@media only screen and (max-width:479px) {
table.mj-full-width-mobile { width: 100% !important; }
td.mj-full-width-mobile { width: auto !important; }
}
</style>
<style type="text/css">
.border-fix > table {
border-collapse: separate !important;
}
.border-fix > table > tbody > tr > td {
border-radius: 3px;
}
</style>
</head>
<body style="word-spacing:normal;background-color:#e6e9ef;">
<div class="border-fix" style="background-color:#e6e9ef;" lang="und" dir="auto">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="border-fix-outlook" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div class="border-fix" style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px 0px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#175ddc;background-color:#175ddc;width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td>
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#175ddc" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;border-radius:4px 4px 0px 0px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;border-radius:4px 4px 0px 0px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:580px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 5px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:150px;">
<img alt src="https://bitwarden.com/images/logo-horizontal-white.png" style="border:0;display:block;outline:none;text-decoration:none;height:30px;width:100%;font-size:16px;" width="150" height="30">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Main Content Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 0px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:15px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px 15px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.</div>
</td>
</tr>
<tr>
<td align="left" style="font-size:0px;padding:10px 15px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;">Questions? Contact
<a href="mailto:support@bitwarden.com" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;">support@bitwarden.com</a></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#ffffff" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#ffffff;background-color:#ffffff;margin:0px auto;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#ffffff;background-color:#ffffff;width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0 20px 20px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Learn More Section -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:0px 20px 10px 20px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" width="660px" ><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:620px;" width="620" bgcolor="#f6f6f6" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#f6f6f6;background-color:#f6f6f6;margin:0px auto;border-radius:0px 0px 4px 4px;max-width:620px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#f6f6f6;background-color:#f6f6f6;width:100%;border-radius:0px 0px 4px 4px;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:5px 10px 10px 10px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:420px;" ><![endif]-->
<div class="mj-column-per-70 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:16px;line-height:24px;text-align:left;color:#1B2029;"><p style="font-size: 18px; line-height: 28px; font-weight: bold;">
Learn more about Bitwarden
</p>
Find user guides, product documentation, and videos on the
<a href="https://bitwarden.com/help/" class="link" style="text-decoration: none; color: #175ddc; font-weight: 600;"> Bitwarden Help Center</a>.</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:180px;" ><![endif]-->
<div class="mj-column-per-30 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" class="mj-bw-learn-more-footer-responsive-img" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:collapse;border-spacing:0px;">
<tbody>
<tr>
<td style="width:94px;">
<img alt src="https://assets.bitwarden.com/email/v1/spot-community.png" style="border:0;display:block;outline:none;text-decoration:none;height:auto;width:100%;font-size:16px;" width="94" height="auto">
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
<!-- Footer -->
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:660px;" width="660" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin:0px auto;max-width:660px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:660px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="center" style="font-size:0px;padding:0;word-break:break-word;">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" ><tr><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://x.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-x.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.reddit.com/r/Bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-reddit.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://community.bitwarden.com/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-discourse.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://github.com/bitwarden" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-github.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.youtube.com/channel/UCId9a_jQqvJre0_dE2lE_Rw" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-youtube.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.linkedin.com/company/bitwarden1/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-linkedin.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td><td><![endif]-->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="float:none;display:inline-table;">
<tbody>
<tr>
<td style="padding:10px;vertical-align:middle;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-radius:3px;width:30px;">
<tbody>
<tr>
<td style="font-size:0;height:30px;vertical-align:middle;width:30px;">
<a href="https://www.facebook.com/bitwarden/" target="_blank">
<img alt height="30" src="https://assets.bitwarden.com/email/v1/mail-facebook.png" style="border-radius:3px;display:block;" width="30">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
<tr>
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;font-size:12px;line-height:16px;text-align:center;color:#5A6D91;"><p style="margin-bottom: 5px">
© 2025 Bitwarden Inc. 1 N. Calle Cesar Chavez, Suite 102, Santa
Barbara, CA, USA
</p>
<p style="margin-top: 5px">
Always confirm you are on a trusted Bitwarden domain before logging
in:<br>
<a href="https://bitwarden.com/">bitwarden.com</a> |
<a href="https://bitwarden.com/help/emails-from-bitwarden/">Learn why we include this</a>
</p></div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>

View File

@ -0,0 +1,6 @@
Your Bitwarden Premium subscription renews in 15 days. The price is updating to {{BaseMonthlyRenewalPrice}}/month, billed annually.
As an existing Bitwarden customer, you will receive a one-time {{DiscountAmount}} loyalty discount for this renewal.
This renewal now will be {{DiscountedMonthlyRenewalPrice}}/month, billed annually.
Questions? Contact support@bitwarden.com

View File

@ -45,6 +45,9 @@ using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using V1_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
using V2_RevokeUsersCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
namespace Bit.Core.OrganizationFeatures;
public static class OrganizationServiceCollectionExtensions
@ -133,7 +136,6 @@ public static class OrganizationServiceCollectionExtensions
{
services.AddScoped<IRemoveOrganizationUserCommand, RemoveOrganizationUserCommand>();
services.AddScoped<IRevokeNonCompliantOrganizationUserCommand, RevokeNonCompliantOrganizationUserCommand>();
services.AddScoped<IRevokeOrganizationUserCommand, RevokeOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserCommand, UpdateOrganizationUserCommand>();
services.AddScoped<IUpdateOrganizationUserGroupsCommand, UpdateOrganizationUserGroupsCommand>();
services.AddScoped<IConfirmOrganizationUserCommand, ConfirmOrganizationUserCommand>();
@ -143,6 +145,11 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IDeleteClaimedOrganizationUserAccountCommand, DeleteClaimedOrganizationUserAccountCommand>();
services.AddScoped<IDeleteClaimedOrganizationUserAccountValidator, DeleteClaimedOrganizationUserAccountValidator>();
services.AddScoped<V1_RevokeUsersCommand.IRevokeOrganizationUserCommand, V1_RevokeUsersCommand.RevokeOrganizationUserCommand>();
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserCommand, V2_RevokeUsersCommand.RevokeOrganizationUserCommand>();
services.AddScoped<V2_RevokeUsersCommand.IRevokeOrganizationUserValidator, V2_RevokeUsersCommand.RevokeOrganizationUsersValidator>();
}
private static void AddOrganizationApiKeyCommandsQueries(this IServiceCollection services)
@ -197,6 +204,7 @@ public static class OrganizationServiceCollectionExtensions
services.AddScoped<IInviteOrganizationUsersCommand, InviteOrganizationUsersCommand>();
services.AddScoped<ISendOrganizationInvitesCommand, SendOrganizationInvitesCommand>();
services.AddScoped<IResendOrganizationInviteCommand, ResendOrganizationInviteCommand>();
services.AddScoped<IBulkResendOrganizationInvitesCommand, BulkResendOrganizationInvitesCommand>();
services.AddScoped<IInviteUsersValidator, InviteOrganizationUsersValidator>();
services.AddScoped<IInviteUsersOrganizationValidator, InviteUsersOrganizationValidator>();

View File

@ -0,0 +1,12 @@
using Bit.Core.SecretsManager.Entities;
namespace Bit.Core.SecretsManager.Repositories;
public interface ISecretVersionRepository
{
Task<SecretVersion?> GetByIdAsync(Guid id);
Task<IEnumerable<SecretVersion>> GetManyBySecretIdAsync(Guid secretId);
Task<IEnumerable<SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids);
Task<SecretVersion> CreateAsync(SecretVersion secretVersion);
Task DeleteManyByIdAsync(IEnumerable<Guid> ids);
}

View File

@ -0,0 +1,31 @@
using Bit.Core.SecretsManager.Entities;
namespace Bit.Core.SecretsManager.Repositories.Noop;
public class NoopSecretVersionRepository : ISecretVersionRepository
{
public Task<SecretVersion?> GetByIdAsync(Guid id)
{
return Task.FromResult(null as SecretVersion);
}
public Task<IEnumerable<SecretVersion>> GetManyBySecretIdAsync(Guid secretId)
{
return Task.FromResult(Enumerable.Empty<SecretVersion>());
}
public Task<SecretVersion> CreateAsync(SecretVersion secretVersion)
{
return Task.FromResult(secretVersion);
}
public Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
return Task.CompletedTask;
}
public Task<IEnumerable<SecretVersion>> GetManyByIdsAsync(IEnumerable<Guid> ids)
{
return Task.FromResult(Enumerable.Empty<SecretVersion>());
}
}

View File

@ -483,7 +483,7 @@ public class GlobalSettings : IGlobalSettings
public string CertificatePassword { get; set; }
public string RedisConnectionString { get; set; }
public string CosmosConnectionString { get; set; }
public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZWtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzM0NTY2NDAwLCJleHAiOjE3NjQ5NzkyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiNjg3OCIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwicHJvZHVjdCI6IkJpdHdhcmRlbiJ9.TYc88W_t2t0F2AJV3rdyKwGyQKrKFriSAzm1tWFNHNR9QizfC-8bliGdT4Wgeie-ynCXs9wWaF-sKC5emg--qS7oe2iIt67Qd88WS53AwgTvAddQRA4NhGB1R7VM8GAikLieSos-DzzwLYRgjZdmcsprItYGSJuY73r-7-F97ta915majBytVxGF966tT9zF1aYk0bA8FS6DcDYkr5f7Nsy8daS_uIUAgNa_agKXtmQPqKujqtUb6rgWEpSp4OcQcG-8Dpd5jHqoIjouGvY-5LTgk5WmLxi_m-1QISjxUJrUm-UGao3_VwV5KFGqYrz8csdTl-HS40ihWcsWnrV0ug";
public string LicenseKey { get; set; } = "eyJhbGciOiJQUzI1NiIsImtpZCI6IklkZW50aXR5U2VydmVyTGljZW5zZUtleS83Y2VhZGJiNzgxMzA0NjllODgwNjg5MTAyNTQxNGYxNiIsInR5cCI6ImxpY2Vuc2Urand0In0.eyJpc3MiOiJodHRwczovL2R1ZW5kZXNvZnR3YXJlLmNvbSIsImF1ZCI6IklkZW50aXR5U2VydmVyIiwiaWF0IjoxNzY1MDY1NjAwLCJleHAiOjE3OTY1MTUyMDAsImNvbXBhbnlfbmFtZSI6IkJpdHdhcmRlbiBJbmMuIiwiY29udGFjdF9pbmZvIjoiY29udGFjdEBkdWVuZGVzb2Z0d2FyZS5jb20iLCJlZGl0aW9uIjoiU3RhcnRlciIsImlkIjoiOTUxNSIsImZlYXR1cmUiOlsiaXN2IiwidW5saW1pdGVkX2NsaWVudHMiXSwiY2xpZW50X2xpbWl0IjowfQ.rWUsq-XBKNwPG7BRKG-vShXHuyHLHJCh0sEWdWT4Rkz4ArIPOAepEp9wNya-hxFKkBTFlPaQ5IKk4wDTvkQkuq1qaI_v6kSCdaP9fvXp0rmh4KcFEffVLB-wAOK2S2Cld5DzdyCoskUUfwNQP7xuLsz2Ydxe_whSRIdv8bsMbvTC3Kl8PYZPZ4MxqW8rSZ_mEuCpSe5-Q40sB7aiu_7YmWLJaKrfBTIqYH-XuzQj36Aemoei0efcntej-gvxovy-5SiSEsGuRZj41rjEZYOuj5KgHihJViO1VDHK6CNtlu2Ks8bkv6G2hO-TkF16Y28ywEG_beLEf_s5dzhbDBDbvA";
/// <summary>
/// Sliding lifetime of a refresh token in seconds.
///
@ -732,7 +732,7 @@ public class GlobalSettings : IGlobalSettings
public class ExtendedCacheSettings
{
public bool EnableDistributedCache { get; set; } = true;
public bool UseSharedRedisCache { get; set; } = true;
public bool UseSharedDistributedCache { get; set; } = true;
public IConnectionStringSettings Redis { get; set; } = new ConnectionStringSettings();
public TimeSpan Duration { get; set; } = TimeSpan.FromMinutes(30);
public bool IsFailSafeEnabled { get; set; } = true;

View File

@ -140,7 +140,7 @@ services.AddExtendedCache("MyFeatureCache", globalSettings, new GlobalSettings.E
// Option 4: Isolated Redis for specialized features
services.AddExtendedCache("SpecializedCache", globalSettings, new GlobalSettings.ExtendedCacheSettings
{
UseSharedRedisCache = false,
UseSharedDistributedCache = false,
Redis = new GlobalSettings.ConnectionStringSettings
{
ConnectionString = "localhost:6379,ssl=false"

View File

@ -1,4 +1,6 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data.Organizations;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Core.Utilities;
@ -11,7 +13,12 @@ public static class EventIntegrationsCacheConstants
/// <summary>
/// The base cache name used for storing event integration data.
/// </summary>
public static readonly string CacheName = "EventIntegrations";
public const string CacheName = "EventIntegrations";
/// <summary>
/// Duration TimeSpan for adding OrganizationIntegrationConfigurationDetails to the cache.
/// </summary>
public static readonly TimeSpan DurationForOrganizationIntegrationConfigurationDetails = TimeSpan.FromDays(1);
/// <summary>
/// Builds a deterministic cache key for a <see cref="Group"/>.
@ -20,10 +27,8 @@ public static class EventIntegrationsCacheConstants
/// <returns>
/// A cache key for this Group.
/// </returns>
public static string BuildCacheKeyForGroup(Guid groupId)
{
return $"Group:{groupId:N}";
}
public static string BuildCacheKeyForGroup(Guid groupId) =>
$"Group:{groupId:N}";
/// <summary>
/// Builds a deterministic cache key for an <see cref="Organization"/>.
@ -32,10 +37,8 @@ public static class EventIntegrationsCacheConstants
/// <returns>
/// A cache key for the Organization.
/// </returns>
public static string BuildCacheKeyForOrganization(Guid organizationId)
{
return $"Organization:{organizationId:N}";
}
public static string BuildCacheKeyForOrganization(Guid organizationId) =>
$"Organization:{organizationId:N}";
/// <summary>
/// Builds a deterministic cache key for an organization user <see cref="OrganizationUserUserDetails"/>.
@ -45,8 +48,37 @@ public static class EventIntegrationsCacheConstants
/// <returns>
/// A cache key for the user.
/// </returns>
public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId)
{
return $"OrganizationUserUserDetails:{organizationId:N}:{userId:N}";
}
public static string BuildCacheKeyForOrganizationUser(Guid organizationId, Guid userId) =>
$"OrganizationUserUserDetails:{organizationId:N}:{userId:N}";
/// <summary>
/// Builds a deterministic cache key for an organization's integration configuration details
/// <see cref="OrganizationIntegrationConfigurationDetails"/>.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization to which the user belongs.</param>
/// <param name="integrationType">The <see cref="IntegrationType"/> of the integration.</param>
/// <param name="eventType">The <see cref="EventType"/> of the event configured. Can be null to apply to all events.</param>
/// <returns>
/// A cache key for the configuration details.
/// </returns>
public static string BuildCacheKeyForOrganizationIntegrationConfigurationDetails(
Guid organizationId,
IntegrationType integrationType,
EventType? eventType
) => $"OrganizationIntegrationConfigurationDetails:{organizationId:N}:{integrationType}:{eventType}";
/// <summary>
/// Builds a deterministic tag for tagging an organization's integration configuration details. This tag is then
/// used to tag all of the <see cref="OrganizationIntegrationConfigurationDetails"/> that result from this
/// integration, which allows us to remove all relevant entries when an integration is changed or removed.
/// </summary>
/// <param name="organizationId">The unique identifier of the organization to which the user belongs.</param>
/// <param name="integrationType">The <see cref="IntegrationType"/> of the integration.</param>
/// <returns>
/// A cache tag to use for the configuration details.
/// </returns>
public static string BuildCacheTagForOrganizationIntegration(
Guid organizationId,
IntegrationType integrationType
) => $"OrganizationIntegration:{organizationId:N}:{integrationType}";
}

View File

@ -18,9 +18,12 @@ public static class ExtendedCacheServiceCollectionExtensions
/// Adds a new, named Fusion Cache <see href="https://github.com/ZiggyCreatures/FusionCache"/> to the service
/// collection. If an existing cache of the same name is found, it will do nothing.<br/>
/// <br/>
/// <b>Note</b>: When re-using the existing Redis cache, it is expected to call this method <b>after</b> calling
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds,
/// configures, and re-uses all the shared Redis architecture.
/// <b>Note</b>: When re-using an existing distributed cache, it is expected to call this method <b>after</b> calling
/// <code>services.AddDistributedCache(globalSettings)</code><br />This ensures that DI correctly finds
/// and re-uses the shared distributed cache infrastructure.<br />
/// <br />
/// <b>Backplane</b>: Cross-instance cache invalidation is only available when using Redis.
/// Non-Redis distributed caches operate with eventual consistency across multiple instances.
/// </summary>
public static IServiceCollection AddExtendedCache(
this IServiceCollection services,
@ -72,12 +75,21 @@ public static class ExtendedCacheServiceCollectionExtensions
if (!settings.EnableDistributedCache)
return services;
if (settings.UseSharedRedisCache)
if (settings.UseSharedDistributedCache)
{
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
if (!CoreHelpers.SettingHasValue(globalSettings.DistributedCache.Redis.ConnectionString))
{
// Using Shared Non-Redis Distributed Cache:
// 1. Assume IDistributedCache is already registered (e.g., Cosmos, SQL Server)
// 2. Backplane not supported (Redis-only feature, requires pub/sub)
fusionCacheBuilder
.TryWithRegisteredDistributedCache();
return services;
}
// Using Shared Redis, TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
services.TryAddSingleton<IConnectionMultiplexer>(sp =>
CreateConnectionMultiplexer(sp, cacheName, globalSettings.DistributedCache.Redis.ConnectionString));
@ -92,13 +104,13 @@ public static class ExtendedCacheServiceCollectionExtensions
});
services.TryAddSingleton<IFusionCacheBackplane>(sp =>
{
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisBackplane(new RedisBackplaneOptions
{
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return new RedisBackplane(new RedisBackplaneOptions
{
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
ConnectionMultiplexerFactory = () => Task.FromResult(mux)
});
});
fusionCacheBuilder
.WithRegisteredDistributedCache()
@ -107,10 +119,21 @@ public static class ExtendedCacheServiceCollectionExtensions
return services;
}
// Using keyed Redis / Distributed Cache. Create all pieces as keyed services.
// Using keyed Distributed Cache. Create/Reuse all pieces as keyed services.
if (!CoreHelpers.SettingHasValue(settings.Redis.ConnectionString))
{
// Using Keyed Non-Redis Distributed Cache:
// 1. Assume IDistributedCache (e.g., Cosmos, SQL Server) is already registered with cacheName as key
// 2. Backplane not supported (Redis-only feature, requires pub/sub)
fusionCacheBuilder
.TryWithRegisteredKeyedDistributedCache(serviceKey: cacheName);
return services;
}
// Using Keyed Redis: TryAdd and reuse all pieces (multiplexer, distributed cache and backplane)
services.TryAddKeyedSingleton<IConnectionMultiplexer>(
cacheName,

View File

@ -14,7 +14,7 @@ public class NormalCipherPermissions
throw new Exception("Cipher needs to belong to a user or an organization.");
}
if (user.Id == cipherDetails.UserId)
if (cipherDetails.OrganizationId == null && user.Id == cipherDetails.UserId)
{
return true;
}

View File

@ -990,11 +990,6 @@ public class CipherService : ICipherService
throw new BadRequestException("One or more ciphers do not belong to you.");
}
if (cipher.ArchivedDate.HasValue)
{
throw new BadRequestException("Cipher cannot be shared with organization because it is archived.");
}
var attachments = cipher.GetAttachments();
var hasAttachments = attachments?.Any() ?? false;
var org = await _organizationRepository.GetByIdAsync(organizationId);

View File

@ -160,16 +160,16 @@ public class CustomTokenRequestValidator : BaseRequestValidator<CustomTokenReque
}
// Key connector data should have already been set in the decryption options
// for backwards compatibility we set them this way too. We can eventually get rid of this
// when all clients don't read them from the existing locations.
// for backwards compatibility we set them this way too. We can eventually get rid of this once we clean up
// ResetMasterPassword
if (!context.Result.CustomResponse.TryGetValue("UserDecryptionOptions", out var userDecryptionOptionsObj) ||
userDecryptionOptionsObj is not UserDecryptionOptions userDecryptionOptions)
{
return Task.CompletedTask;
}
if (userDecryptionOptions is { KeyConnectorOption: { } })
{
context.Result.CustomResponse["KeyConnectorUrl"] = userDecryptionOptions.KeyConnectorOption.KeyConnectorUrl;
context.Result.CustomResponse["ResetMasterPassword"] = false;
}

View File

@ -20,10 +20,9 @@ public class OrganizationIntegrationConfigurationRepository : Repository<Organiz
: base(connectionString, readOnlyConnectionString)
{ }
public async Task<List<OrganizationIntegrationConfigurationDetails>> GetConfigurationDetailsAsync(
Guid organizationId,
IntegrationType integrationType,
EventType eventType)
public async Task<List<OrganizationIntegrationConfigurationDetails>>
GetManyByEventTypeOrganizationIdIntegrationType(EventType eventType, Guid organizationId,
IntegrationType integrationType)
{
using (var connection = new SqlConnection(ConnectionString))
{

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