mirror of
https://github.com/bitwarden/server.git
synced 2025-12-11 22:15:45 -06:00
Merge branch 'main' into km/pm-25652
This commit is contained in:
commit
bc70a69fec
4
.github/ISSUE_TEMPLATE/bw-lite.yml
vendored
4
.github/ISSUE_TEMPLATE/bw-lite.yml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Bitwarden Lite Deployment Bug Report
|
name: Bitwarden lite Deployment Bug Report
|
||||||
description: File a bug report
|
description: File a bug report
|
||||||
labels: [bug, bw-lite-deploy]
|
labels: [bug, bw-lite-deploy]
|
||||||
body:
|
body:
|
||||||
@ -74,7 +74,7 @@ body:
|
|||||||
id: epic-label
|
id: epic-label
|
||||||
attributes:
|
attributes:
|
||||||
label: Issue-Link
|
label: Issue-Link
|
||||||
description: Link to our pinned issue, tracking all Bitwarden Lite
|
description: Link to our pinned issue, tracking all Bitwarden lite
|
||||||
value: |
|
value: |
|
||||||
https://github.com/bitwarden/server/issues/2480
|
https://github.com/bitwarden/server/issues/2480
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
3
.github/renovate.json5
vendored
3
.github/renovate.json5
vendored
@ -44,6 +44,7 @@
|
|||||||
{
|
{
|
||||||
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
||||||
groupName: "sdk-internal",
|
groupName: "sdk-internal",
|
||||||
|
dependencyDashboardApproval: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchManagers: ["dockerfile", "docker-compose"],
|
matchManagers: ["dockerfile", "docker-compose"],
|
||||||
@ -63,7 +64,6 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchPackageNames: [
|
matchPackageNames: [
|
||||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
|
||||||
"DuoUniversal",
|
"DuoUniversal",
|
||||||
"Fido2.AspNet",
|
"Fido2.AspNet",
|
||||||
"Duende.IdentityServer",
|
"Duende.IdentityServer",
|
||||||
@ -137,6 +137,7 @@
|
|||||||
"AspNetCoreRateLimit",
|
"AspNetCoreRateLimit",
|
||||||
"AspNetCoreRateLimit.Redis",
|
"AspNetCoreRateLimit.Redis",
|
||||||
"Azure.Data.Tables",
|
"Azure.Data.Tables",
|
||||||
|
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||||
"Azure.Messaging.EventGrid",
|
"Azure.Messaging.EventGrid",
|
||||||
"Azure.Messaging.ServiceBus",
|
"Azure.Messaging.ServiceBus",
|
||||||
"Azure.Storage.Blobs",
|
"Azure.Storage.Blobs",
|
||||||
|
|||||||
51
.github/workflows/build.yml
vendored
51
.github/workflows/build.yml
vendored
@ -185,13 +185,6 @@ jobs:
|
|||||||
- name: Log in to ACR - production subscription
|
- name: Log in to ACR - production subscription
|
||||||
run: az acr login -n bitwardenprod
|
run: az acr login -n bitwardenprod
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
|
||||||
id: retrieve-secret-pat
|
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
|
||||||
with:
|
|
||||||
keyvault: "bitwarden-ci"
|
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
|
||||||
|
|
||||||
########## Generate image tag and build Docker image ##########
|
########## Generate image tag and build Docker image ##########
|
||||||
- name: Generate Docker image tag
|
- name: Generate Docker image tag
|
||||||
id: tag
|
id: tag
|
||||||
@ -250,8 +243,6 @@ jobs:
|
|||||||
linux/arm64
|
linux/arm64
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.image-tags.outputs.tags }}
|
tags: ${{ steps.image-tags.outputs.tags }}
|
||||||
secrets: |
|
|
||||||
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
|
|
||||||
|
|
||||||
- name: Install Cosign
|
- name: Install Cosign
|
||||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
@ -280,7 +271,7 @@ jobs:
|
|||||||
output-format: sarif
|
output-format: sarif
|
||||||
|
|
||||||
- name: Upload Grype results to GitHub
|
- name: Upload Grype results to GitHub
|
||||||
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
|
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||||
with:
|
with:
|
||||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||||
@ -479,20 +470,29 @@ jobs:
|
|||||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
- name: Get Azure Key Vault secrets
|
||||||
id: retrieve-secret-pat
|
id: get-kv-secrets
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||||
with:
|
with:
|
||||||
keyvault: "bitwarden-ci"
|
keyvault: gh-org-bitwarden
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||||
|
|
||||||
- name: Log out from Azure
|
- name: Log out from Azure
|
||||||
uses: bitwarden/gh-actions/azure-logout@main
|
uses: bitwarden/gh-actions/azure-logout@main
|
||||||
|
|
||||||
- name: Trigger Bitwarden Lite build
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||||
|
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: self-host
|
||||||
|
|
||||||
|
- name: Trigger Bitwarden lite build
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
owner: 'bitwarden',
|
owner: 'bitwarden',
|
||||||
@ -520,20 +520,29 @@ jobs:
|
|||||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||||
|
|
||||||
- name: Retrieve GitHub PAT secrets
|
- name: Get Azure Key Vault secrets
|
||||||
id: retrieve-secret-pat
|
id: get-kv-secrets
|
||||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||||
with:
|
with:
|
||||||
keyvault: "bitwarden-ci"
|
keyvault: gh-org-bitwarden
|
||||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||||
|
|
||||||
- name: Log out from Azure
|
- name: Log out from Azure
|
||||||
uses: bitwarden/gh-actions/azure-logout@main
|
uses: bitwarden/gh-actions/azure-logout@main
|
||||||
|
|
||||||
|
- name: Generate GH App token
|
||||||
|
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
|
||||||
|
id: app-token
|
||||||
|
with:
|
||||||
|
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||||
|
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||||
|
owner: ${{ github.repository_owner }}
|
||||||
|
repositories: devops
|
||||||
|
|
||||||
- name: Trigger k8s deploy
|
- name: Trigger k8s deploy
|
||||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||||
with:
|
with:
|
||||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
github-token: ${{ steps.app-token.outputs.token }}
|
||||||
script: |
|
script: |
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
await github.rest.actions.createWorkflowDispatch({
|
||||||
owner: 'bitwarden',
|
owner: 'bitwarden',
|
||||||
|
|||||||
27
.github/workflows/test-database.yml
vendored
27
.github/workflows/test-database.yml
vendored
@ -62,7 +62,7 @@ jobs:
|
|||||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
- name: Add MariaDB for Bitwarden Lite
|
- name: Add MariaDB for Bitwarden lite
|
||||||
# Use a different port than MySQL
|
# Use a different port than MySQL
|
||||||
run: |
|
run: |
|
||||||
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
||||||
@ -133,7 +133,7 @@ jobs:
|
|||||||
# Default Sqlite
|
# Default Sqlite
|
||||||
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
BW_TEST_DATABASES__3__TYPE: "Sqlite"
|
||||||
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
|
||||||
# Bitwarden Lite MariaDB
|
# Bitwarden lite MariaDB
|
||||||
BW_TEST_DATABASES__4__TYPE: "MySql"
|
BW_TEST_DATABASES__4__TYPE: "MySql"
|
||||||
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||||
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||||
@ -262,3 +262,26 @@ jobs:
|
|||||||
working-directory: "dev"
|
working-directory: "dev"
|
||||||
run: docker compose down
|
run: docker compose down
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
|
|
||||||
|
validate-migration-naming:
|
||||||
|
name: Validate new migration naming and order
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Validate new migrations for pull request
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
run: |
|
||||||
|
git fetch origin main:main
|
||||||
|
pwsh dev/verify_migrations.ps1 -BaseRef main
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
- name: Validate new migrations for push
|
||||||
|
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||||
|
run: pwsh dev/verify_migrations.ps1 -BaseRef HEAD~1
|
||||||
|
shell: pwsh
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
|
||||||
<Version>2025.11.1</Version>
|
<Version>2025.12.0</Version>
|
||||||
|
|
||||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
|
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
|
||||||
services.AddSingleton<ISecretRepository, SecretRepository>();
|
services.AddSingleton<ISecretRepository, SecretRepository>();
|
||||||
|
services.AddSingleton<ISecretVersionRepository, SecretVersionRepository>();
|
||||||
services.AddSingleton<IProjectRepository, ProjectRepository>();
|
services.AddSingleton<IProjectRepository, ProjectRepository>();
|
||||||
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
|
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -61,17 +61,15 @@ public class GroupsController : Controller
|
|||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<IActionResult> Get(
|
public async Task<IActionResult> Get(
|
||||||
Guid organizationId,
|
Guid organizationId,
|
||||||
[FromQuery] string filter,
|
[FromQuery] GetGroupsQueryParamModel model)
|
||||||
[FromQuery] int? count,
|
|
||||||
[FromQuery] int? startIndex)
|
|
||||||
{
|
{
|
||||||
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex);
|
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, model);
|
||||||
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
|
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
|
||||||
{
|
{
|
||||||
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
|
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
|
||||||
ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()),
|
ItemsPerPage = model.Count,
|
||||||
TotalResults = groupsListQueryResult.totalResults,
|
TotalResults = groupsListQueryResult.totalResults,
|
||||||
StartIndex = startIndex.GetValueOrDefault(1),
|
StartIndex = model.StartIndex,
|
||||||
};
|
};
|
||||||
return Ok(scimListResponseModel);
|
return Ok(scimListResponseModel);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
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.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Scim.Groups.Interfaces;
|
using Bit.Scim.Groups.Interfaces;
|
||||||
|
using Bit.Scim.Models;
|
||||||
|
|
||||||
namespace Bit.Scim.Groups;
|
namespace Bit.Scim.Groups;
|
||||||
|
|
||||||
@ -16,10 +17,16 @@ public class GetGroupsListQuery : IGetGroupsListQuery
|
|||||||
_groupRepository = groupRepository;
|
_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 nameFilter = null;
|
||||||
string externalIdFilter = null;
|
string externalIdFilter = null;
|
||||||
|
|
||||||
|
int count = groupQueryParams.Count;
|
||||||
|
int startIndex = groupQueryParams.StartIndex;
|
||||||
|
string filter = groupQueryParams.Filter;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(filter))
|
if (!string.IsNullOrWhiteSpace(filter))
|
||||||
{
|
{
|
||||||
if (filter.StartsWith("displayName eq "))
|
if (filter.StartsWith("displayName eq "))
|
||||||
@ -53,11 +60,11 @@ public class GetGroupsListQuery : IGetGroupsListQuery
|
|||||||
}
|
}
|
||||||
totalResults = groupList.Count;
|
totalResults = groupList.Count;
|
||||||
}
|
}
|
||||||
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
|
else if (string.IsNullOrWhiteSpace(filter))
|
||||||
{
|
{
|
||||||
groupList = groups.OrderBy(g => g.Name)
|
groupList = groups.OrderBy(g => g.Name)
|
||||||
.Skip(startIndex.Value - 1)
|
.Skip(startIndex - 1)
|
||||||
.Take(count.Value)
|
.Take(count)
|
||||||
.ToList();
|
.ToList();
|
||||||
totalResults = groups.Count;
|
totalResults = groups.Count;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Scim.Models;
|
||||||
|
|
||||||
namespace Bit.Scim.Groups.Interfaces;
|
namespace Bit.Scim.Groups.Interfaces;
|
||||||
|
|
||||||
public interface IGetGroupsListQuery
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -1,5 +1,7 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Bit.Scim.Models;
|
||||||
|
|
||||||
public class GetUsersQueryParamModel
|
public class GetUsersQueryParamModel
|
||||||
{
|
{
|
||||||
public string Filter { get; init; } = string.Empty;
|
public string Filter { get; init; } = string.Empty;
|
||||||
@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Users.Interfaces;
|
using Bit.Scim.Users.Interfaces;
|
||||||
|
|
||||||
namespace Bit.Scim.Users;
|
namespace Bit.Scim.Users;
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
|
using Bit.Scim.Models;
|
||||||
|
|
||||||
namespace Bit.Scim.Users.Interfaces;
|
namespace Bit.Scim.Users.Interfaces;
|
||||||
|
|
||||||
|
|||||||
@ -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.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
|||||||
@ -25,6 +25,12 @@
|
|||||||
"connectionString": "UseDevelopmentStorage=true"
|
"connectionString": "UseDevelopmentStorage=true"
|
||||||
},
|
},
|
||||||
"developmentDirectory": "../../../dev",
|
"developmentDirectory": "../../../dev",
|
||||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
"pricingUri": "https://billingpricing.qa.bitwarden.pw",
|
||||||
|
"mail": {
|
||||||
|
"smtp": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 10250
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,11 @@
|
|||||||
"mail": {
|
"mail": {
|
||||||
"sendGridApiKey": "SECRET",
|
"sendGridApiKey": "SECRET",
|
||||||
"amazonConfigSetName": "Email",
|
"amazonConfigSetName": "Email",
|
||||||
"replyToEmail": "no-reply@bitwarden.com"
|
"replyToEmail": "no-reply@bitwarden.com",
|
||||||
|
"smtp": {
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 10250
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"identityServer": {
|
"identityServer": {
|
||||||
"certificateThumbprint": "SECRET"
|
"certificateThumbprint": "SECRET"
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -200,6 +200,38 @@ public class GroupsControllerTests : IClassFixture<ScimApplicationFactory>, IAsy
|
|||||||
AssertHelper.AssertPropertyEqual(expectedResponse, responseModel);
|
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]
|
[Fact]
|
||||||
public async Task Post_Success()
|
public async Task Post_Success()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Scim.Groups;
|
using Bit.Scim.Groups;
|
||||||
|
using Bit.Scim.Models;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
using Bit.Test.Common.Helpers;
|
using Bit.Test.Common.Helpers;
|
||||||
@ -24,7 +25,7 @@ public class GetGroupsListCommandTests
|
|||||||
.GetManyByOrganizationIdAsync(organizationId)
|
.GetManyByOrganizationIdAsync(organizationId)
|
||||||
.Returns(groups);
|
.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.Skip(startIndex - 1).Take(count).ToList(), result.groupList);
|
||||||
AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults);
|
AssertHelper.AssertPropertyEqual(groups.Count, result.totalResults);
|
||||||
@ -47,7 +48,7 @@ public class GetGroupsListCommandTests
|
|||||||
.GetManyByOrganizationIdAsync(organizationId)
|
.GetManyByOrganizationIdAsync(organizationId)
|
||||||
.Returns(groups);
|
.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(expectedGroupList, result.groupList);
|
||||||
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
||||||
@ -67,7 +68,7 @@ public class GetGroupsListCommandTests
|
|||||||
.GetManyByOrganizationIdAsync(organizationId)
|
.GetManyByOrganizationIdAsync(organizationId)
|
||||||
.Returns(groups);
|
.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(expectedGroupList, result.groupList);
|
||||||
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
||||||
@ -90,7 +91,7 @@ public class GetGroupsListCommandTests
|
|||||||
.GetManyByOrganizationIdAsync(organizationId)
|
.GetManyByOrganizationIdAsync(organizationId)
|
||||||
.Returns(groups);
|
.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(expectedGroupList, result.groupList);
|
||||||
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
||||||
@ -112,7 +113,7 @@ public class GetGroupsListCommandTests
|
|||||||
.GetManyByOrganizationIdAsync(organizationId)
|
.GetManyByOrganizationIdAsync(organizationId)
|
||||||
.Returns(groups);
|
.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(expectedGroupList, result.groupList);
|
||||||
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
AssertHelper.AssertPropertyEqual(expectedTotalResults, result.totalResults);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Scim.Models;
|
||||||
using Bit.Scim.Users;
|
using Bit.Scim.Users;
|
||||||
using Bit.Test.Common.AutoFixture;
|
using Bit.Test.Common.AutoFixture;
|
||||||
using Bit.Test.Common.AutoFixture.Attributes;
|
using Bit.Test.Common.AutoFixture.Attributes;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
using System.Text.Json;
|
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.RestoreUser.v1;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
|||||||
@ -18,11 +18,11 @@ if ($LASTEXITCODE -ne 0) {
|
|||||||
# Api internal & public
|
# Api internal & public
|
||||||
Set-Location "../../src/Api"
|
Set-Location "../../src/Api"
|
||||||
dotnet build
|
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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
exit $LASTEXITCODE
|
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) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
exit $LASTEXITCODE
|
exit $LASTEXITCODE
|
||||||
}
|
}
|
||||||
|
|||||||
132
dev/verify_migrations.ps1
Normal file
132
dev/verify_migrations.ps1
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Validates that new database migration files follow naming conventions and chronological order.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
This script validates migration files in util/Migrator/DbScripts/ to ensure:
|
||||||
|
1. New migrations follow the naming format: YYYY-MM-DD_NN_Description.sql
|
||||||
|
2. New migrations are chronologically ordered (filename sorts after existing migrations)
|
||||||
|
3. Dates use leading zeros (e.g., 2025-01-05, not 2025-1-5)
|
||||||
|
4. A 2-digit sequence number is included (e.g., _00, _01)
|
||||||
|
|
||||||
|
.PARAMETER BaseRef
|
||||||
|
The base git reference to compare against (e.g., 'main', 'HEAD~1')
|
||||||
|
|
||||||
|
.PARAMETER CurrentRef
|
||||||
|
The current git reference (defaults to 'HEAD')
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# For pull requests - compare against main branch
|
||||||
|
.\verify_migrations.ps1 -BaseRef main
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# For pushes - compare against previous commit
|
||||||
|
.\verify_migrations.ps1 -BaseRef HEAD~1
|
||||||
|
#>
|
||||||
|
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$BaseRef,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[string]$CurrentRef = "HEAD"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use invariant culture for consistent string comparison
|
||||||
|
[System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::InvariantCulture
|
||||||
|
|
||||||
|
$migrationPath = "util/Migrator/DbScripts"
|
||||||
|
|
||||||
|
# Get list of migrations from base reference
|
||||||
|
try {
|
||||||
|
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$migrationPath/*.sql" 2>$null | Sort-Object
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||||
|
$baseMigrations = @()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "Warning: Could not retrieve migrations from base reference '$BaseRef'"
|
||||||
|
$baseMigrations = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get list of migrations from current reference
|
||||||
|
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$migrationPath/*.sql" | Sort-Object
|
||||||
|
|
||||||
|
# Find added migrations
|
||||||
|
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
|
||||||
|
|
||||||
|
if ($addedMigrations.Count -eq 0) {
|
||||||
|
Write-Host "No new migration files added."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "New migration files detected:"
|
||||||
|
$addedMigrations | ForEach-Object { Write-Host " $_" }
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Get the last migration from base reference
|
||||||
|
if ($baseMigrations.Count -eq 0) {
|
||||||
|
Write-Host "No previous migrations found (initial commit?). Skipping validation."
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
|
||||||
|
Write-Host "Last migration in base reference: $lastBaseMigration"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Required format regex: YYYY-MM-DD_NN_Description.sql
|
||||||
|
$formatRegex = '^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}_.+\.sql$'
|
||||||
|
|
||||||
|
$validationFailed = $false
|
||||||
|
|
||||||
|
foreach ($migration in $addedMigrations) {
|
||||||
|
$migrationName = Split-Path -Leaf $migration
|
||||||
|
|
||||||
|
# Validate NEW migration filename format
|
||||||
|
if ($migrationName -notmatch $formatRegex) {
|
||||||
|
Write-Host "ERROR: Migration '$migrationName' does not match required format"
|
||||||
|
Write-Host "Required format: YYYY-MM-DD_NN_Description.sql"
|
||||||
|
Write-Host " - YYYY: 4-digit year"
|
||||||
|
Write-Host " - MM: 2-digit month with leading zero (01-12)"
|
||||||
|
Write-Host " - DD: 2-digit day with leading zero (01-31)"
|
||||||
|
Write-Host " - NN: 2-digit sequence number (00, 01, 02, etc.)"
|
||||||
|
Write-Host "Example: 2025-01-15_00_MyMigration.sql"
|
||||||
|
$validationFailed = $true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compare migration name with last base migration (using ordinal string comparison)
|
||||||
|
if ([string]::CompareOrdinal($migrationName, $lastBaseMigration) -lt 0) {
|
||||||
|
Write-Host "ERROR: New migration '$migrationName' is not chronologically after '$lastBaseMigration'"
|
||||||
|
$validationFailed = $true
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
if ($validationFailed) {
|
||||||
|
Write-Host "FAILED: One or more migrations are incorrectly named or not in chronological order"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "All new migration files must:"
|
||||||
|
Write-Host " 1. Follow the naming format: YYYY-MM-DD_NN_Description.sql"
|
||||||
|
Write-Host " 2. Use leading zeros in dates (e.g., 2025-01-05, not 2025-1-5)"
|
||||||
|
Write-Host " 3. Include a 2-digit sequence number (e.g., _00, _01)"
|
||||||
|
Write-Host " 4. Have a filename that sorts after the last migration in base"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "To fix this issue:"
|
||||||
|
Write-Host " 1. Locate your migration file(s) in util/Migrator/DbScripts/"
|
||||||
|
Write-Host " 2. Rename to follow format: YYYY-MM-DD_NN_Description.sql"
|
||||||
|
Write-Host " 3. Ensure the date is after $lastBaseMigration"
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Example: 2025-01-15_00_AddNewFeature.sql"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "SUCCESS: All new migrations are correctly named and in chronological order"
|
||||||
|
exit 0
|
||||||
@ -473,6 +473,7 @@ public class OrganizationsController : Controller
|
|||||||
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
organization.UseOrganizationDomains = model.UseOrganizationDomains;
|
||||||
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
|
||||||
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
|
||||||
|
organization.UsePhishingBlocker = model.UsePhishingBlocker;
|
||||||
|
|
||||||
//secrets
|
//secrets
|
||||||
organization.SmSeats = model.SmSeats;
|
organization.SmSeats = model.SmSeats;
|
||||||
|
|||||||
@ -107,6 +107,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
|
||||||
UseOrganizationDomains = org.UseOrganizationDomains;
|
UseOrganizationDomains = org.UseOrganizationDomains;
|
||||||
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
|
||||||
|
UsePhishingBlocker = org.UsePhishingBlocker;
|
||||||
|
|
||||||
_plans = plans;
|
_plans = plans;
|
||||||
}
|
}
|
||||||
@ -160,6 +161,8 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
public new bool UseSecretsManager { get; set; }
|
public new bool UseSecretsManager { get; set; }
|
||||||
[Display(Name = "Risk Insights")]
|
[Display(Name = "Risk Insights")]
|
||||||
public new bool UseRiskInsights { get; set; }
|
public new bool UseRiskInsights { get; set; }
|
||||||
|
[Display(Name = "Phishing Blocker")]
|
||||||
|
public new bool UsePhishingBlocker { get; set; }
|
||||||
[Display(Name = "Admin Sponsored Families")]
|
[Display(Name = "Admin Sponsored Families")]
|
||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
[Display(Name = "Self Host")]
|
[Display(Name = "Self Host")]
|
||||||
@ -327,6 +330,7 @@ public class OrganizationEditModel : OrganizationViewModel
|
|||||||
existingOrganization.SmServiceAccounts = SmServiceAccounts;
|
existingOrganization.SmServiceAccounts = SmServiceAccounts;
|
||||||
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
|
||||||
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
|
||||||
|
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
|
||||||
return existingOrganization;
|
return existingOrganization;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,6 +75,7 @@ public class OrganizationViewModel
|
|||||||
public int OccupiedSmSeatsCount { get; set; }
|
public int OccupiedSmSeatsCount { get; set; }
|
||||||
public bool UseSecretsManager => Organization.UseSecretsManager;
|
public bool UseSecretsManager => Organization.UseSecretsManager;
|
||||||
public bool UseRiskInsights => Organization.UseRiskInsights;
|
public bool UseRiskInsights => Organization.UseRiskInsights;
|
||||||
|
public bool UsePhishingBlocker => Organization.UsePhishingBlocker;
|
||||||
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
public IEnumerable<OrganizationUserUserDetails> OwnersDetails { get; set; }
|
||||||
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
public IEnumerable<OrganizationUserUserDetails> AdminsDetails { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -156,6 +156,10 @@
|
|||||||
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
|
||||||
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
|
||||||
|
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
|
||||||
|
</div>
|
||||||
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
|
||||||
{
|
{
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
|||||||
public virtual async Task StartAsync(CancellationToken cancellationToken)
|
public virtual async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Wait 20 seconds to allow database to come online
|
// Wait 20 seconds to allow database to come online
|
||||||
await Task.Delay(20000);
|
await Task.Delay(20000, cancellationToken);
|
||||||
|
|
||||||
var maxMigrationAttempts = 10;
|
var maxMigrationAttempts = 10;
|
||||||
for (var i = 1; i <= maxMigrationAttempts; i++)
|
for (var i = 1; i <= maxMigrationAttempts; i++)
|
||||||
@ -41,7 +41,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
|
|||||||
{
|
{
|
||||||
_logger.LogError(e,
|
_logger.LogError(e,
|
||||||
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
|
||||||
await Task.Delay(20000);
|
await Task.Delay(20000, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
using Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
using Bit.Api.AdminConsole.Models.Response.Organizations;
|
||||||
|
using Bit.Core.AdminConsole.EventIntegrations.OrganizationIntegrations.Interfaces;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
using Bit.Core.Repositories;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -12,7 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
|
|||||||
[Authorize("Application")]
|
[Authorize("Application")]
|
||||||
public class OrganizationIntegrationController(
|
public class OrganizationIntegrationController(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IOrganizationIntegrationRepository integrationRepository) : Controller
|
ICreateOrganizationIntegrationCommand createCommand,
|
||||||
|
IUpdateOrganizationIntegrationCommand updateCommand,
|
||||||
|
IDeleteOrganizationIntegrationCommand deleteCommand,
|
||||||
|
IGetOrganizationIntegrationsQuery getQuery) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
public async Task<List<OrganizationIntegrationResponseModel>> GetAsync(Guid organizationId)
|
public async Task<List<OrganizationIntegrationResponseModel>> GetAsync(Guid organizationId)
|
||||||
@ -22,7 +25,7 @@ public class OrganizationIntegrationController(
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var integrations = await integrationRepository.GetManyByOrganizationAsync(organizationId);
|
var integrations = await getQuery.GetManyByOrganizationAsync(organizationId);
|
||||||
return integrations
|
return integrations
|
||||||
.Select(integration => new OrganizationIntegrationResponseModel(integration))
|
.Select(integration => new OrganizationIntegrationResponseModel(integration))
|
||||||
.ToList();
|
.ToList();
|
||||||
@ -36,8 +39,10 @@ public class OrganizationIntegrationController(
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var integration = await integrationRepository.CreateAsync(model.ToOrganizationIntegration(organizationId));
|
var integration = model.ToOrganizationIntegration(organizationId);
|
||||||
return new OrganizationIntegrationResponseModel(integration);
|
var created = await createCommand.CreateAsync(integration);
|
||||||
|
|
||||||
|
return new OrganizationIntegrationResponseModel(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{integrationId:guid}")]
|
[HttpPut("{integrationId:guid}")]
|
||||||
@ -48,14 +53,10 @@ public class OrganizationIntegrationController(
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
var integration = model.ToOrganizationIntegration(organizationId);
|
||||||
if (integration is null || integration.OrganizationId != organizationId)
|
var updated = await updateCommand.UpdateAsync(organizationId, integrationId, integration);
|
||||||
{
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await integrationRepository.ReplaceAsync(model.ToOrganizationIntegration(integration));
|
return new OrganizationIntegrationResponseModel(updated);
|
||||||
return new OrganizationIntegrationResponseModel(integration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{integrationId:guid}")]
|
[HttpDelete("{integrationId:guid}")]
|
||||||
@ -66,13 +67,7 @@ public class OrganizationIntegrationController(
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
var integration = await integrationRepository.GetByIdAsync(integrationId);
|
await deleteCommand.DeleteAsync(organizationId, integrationId);
|
||||||
if (integration is null || integration.OrganizationId != organizationId)
|
|
||||||
{
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await integrationRepository.DeleteAsync(integration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{integrationId:guid}/delete")]
|
[HttpPost("{integrationId:guid}/delete")]
|
||||||
|
|||||||
@ -41,6 +41,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
|||||||
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
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;
|
namespace Bit.Api.AdminConsole.Controllers;
|
||||||
|
|
||||||
@ -71,11 +73,13 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
|||||||
private readonly IFeatureService _featureService;
|
private readonly IFeatureService _featureService;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
|
||||||
|
private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand;
|
||||||
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
|
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
|
||||||
|
private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext;
|
||||||
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
|
||||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||||
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
|
||||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||||
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
|
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
|
||||||
|
|
||||||
public OrganizationUsersController(IOrganizationRepository organizationRepository,
|
public OrganizationUsersController(IOrganizationRepository organizationRepository,
|
||||||
@ -103,10 +107,12 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
|||||||
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
|
||||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||||
IInitPendingOrganizationCommand initPendingOrganizationCommand,
|
IInitPendingOrganizationCommand initPendingOrganizationCommand,
|
||||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
|
V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand,
|
||||||
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
|
||||||
|
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
|
||||||
IAdminRecoverAccountCommand adminRecoverAccountCommand,
|
IAdminRecoverAccountCommand adminRecoverAccountCommand,
|
||||||
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
|
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
|
||||||
|
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -131,7 +137,9 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
|||||||
_featureService = featureService;
|
_featureService = featureService;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
|
||||||
|
_bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand;
|
||||||
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
|
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
|
||||||
|
_revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext;
|
||||||
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
|
||||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||||
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
_initPendingOrganizationCommand = initPendingOrganizationCommand;
|
||||||
@ -273,7 +281,17 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
|||||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
||||||
{
|
{
|
||||||
var userId = _userService.GetProperUserId(User);
|
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>(
|
return new ListResponseModel<OrganizationUserBulkResponseModel>(
|
||||||
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
|
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
|
||||||
}
|
}
|
||||||
@ -483,43 +501,10 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#nullable enable
|
||||||
[HttpPut("{id}/reset-password")]
|
[HttpPut("{id}/reset-password")]
|
||||||
[Authorize<ManageAccountRecoveryRequirement>]
|
[Authorize<ManageAccountRecoveryRequirement>]
|
||||||
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
|
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);
|
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
|
||||||
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
|
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
|
||||||
@ -662,7 +647,29 @@ public class OrganizationUsersController : BaseAdminConsoleController
|
|||||||
[Authorize<ManageUsersRequirement>]
|
[Authorize<ManageUsersRequirement>]
|
||||||
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
|
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")]
|
[HttpPatch("revoke")]
|
||||||
|
|||||||
@ -12,7 +12,6 @@ using Bit.Api.Models.Request.Accounts;
|
|||||||
using Bit.Api.Models.Request.Organizations;
|
using Bit.Api.Models.Request.Organizations;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core;
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
using Bit.Core.AdminConsole.Models.Business.Tokenables;
|
||||||
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
|
||||||
@ -70,6 +69,7 @@ public class OrganizationsController : Controller
|
|||||||
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
private readonly IPolicyRequirementQuery _policyRequirementQuery;
|
||||||
private readonly IPricingClient _pricingClient;
|
private readonly IPricingClient _pricingClient;
|
||||||
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
private readonly IOrganizationUpdateKeysCommand _organizationUpdateKeysCommand;
|
||||||
|
private readonly IOrganizationUpdateCommand _organizationUpdateCommand;
|
||||||
|
|
||||||
public OrganizationsController(
|
public OrganizationsController(
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
@ -94,7 +94,8 @@ public class OrganizationsController : Controller
|
|||||||
IOrganizationDeleteCommand organizationDeleteCommand,
|
IOrganizationDeleteCommand organizationDeleteCommand,
|
||||||
IPolicyRequirementQuery policyRequirementQuery,
|
IPolicyRequirementQuery policyRequirementQuery,
|
||||||
IPricingClient pricingClient,
|
IPricingClient pricingClient,
|
||||||
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand)
|
IOrganizationUpdateKeysCommand organizationUpdateKeysCommand,
|
||||||
|
IOrganizationUpdateCommand organizationUpdateCommand)
|
||||||
{
|
{
|
||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_organizationUserRepository = organizationUserRepository;
|
_organizationUserRepository = organizationUserRepository;
|
||||||
@ -119,6 +120,7 @@ public class OrganizationsController : Controller
|
|||||||
_policyRequirementQuery = policyRequirementQuery;
|
_policyRequirementQuery = policyRequirementQuery;
|
||||||
_pricingClient = pricingClient;
|
_pricingClient = pricingClient;
|
||||||
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
_organizationUpdateKeysCommand = organizationUpdateKeysCommand;
|
||||||
|
_organizationUpdateCommand = organizationUpdateCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
@ -224,36 +226,31 @@ public class OrganizationsController : Controller
|
|||||||
return new OrganizationResponseModel(result.Organization, plan);
|
return new OrganizationResponseModel(result.Organization, plan);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id}")]
|
[HttpPut("{organizationId:guid}")]
|
||||||
public async Task<OrganizationResponseModel> Put(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<IResult> Put(Guid organizationId, [FromBody] OrganizationUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
var orgIdGuid = new Guid(id);
|
// If billing email is being changed, require subscription editing permissions.
|
||||||
|
// Otherwise, organization owner permissions are sufficient.
|
||||||
|
var requiresBillingPermission = model.BillingEmail is not null;
|
||||||
|
var authorized = requiresBillingPermission
|
||||||
|
? await _currentContext.EditSubscription(organizationId)
|
||||||
|
: await _currentContext.OrganizationOwner(organizationId);
|
||||||
|
|
||||||
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
|
if (!authorized)
|
||||||
if (organization == null)
|
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
return TypedResults.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var updateBilling = ShouldUpdateBilling(model, organization);
|
var commandRequest = model.ToCommandRequest(organizationId);
|
||||||
|
var updatedOrganization = await _organizationUpdateCommand.UpdateAsync(commandRequest);
|
||||||
|
|
||||||
var hasRequiredPermissions = updateBilling
|
var plan = await _pricingClient.GetPlan(updatedOrganization.PlanType);
|
||||||
? await _currentContext.EditSubscription(orgIdGuid)
|
return TypedResults.Ok(new OrganizationResponseModel(updatedOrganization, plan));
|
||||||
: await _currentContext.OrganizationOwner(orgIdGuid);
|
|
||||||
|
|
||||||
if (!hasRequiredPermissions)
|
|
||||||
{
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _organizationService.UpdateAsync(model.ToOrganization(organization, _globalSettings), updateBilling);
|
|
||||||
var plan = await _pricingClient.GetPlan(organization.PlanType);
|
|
||||||
return new OrganizationResponseModel(organization, plan);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id}")]
|
[HttpPost("{id}")]
|
||||||
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
|
||||||
public async Task<OrganizationResponseModel> PostPut(string id, [FromBody] OrganizationUpdateRequestModel model)
|
public async Task<IResult> PostPut(Guid id, [FromBody] OrganizationUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
return await Put(id, model);
|
return await Put(id, model);
|
||||||
}
|
}
|
||||||
@ -588,11 +585,4 @@ public class OrganizationsController : Controller
|
|||||||
|
|
||||||
return organization.PlanType;
|
return organization.PlanType;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool ShouldUpdateBilling(OrganizationUpdateRequestModel model, Organization organization)
|
|
||||||
{
|
|
||||||
var organizationNameChanged = model.Name != organization.Name;
|
|
||||||
var billingEmailChanged = model.BillingEmail != organization.BillingEmail;
|
|
||||||
return !_globalSettings.SelfHosted && (organizationNameChanged || billingEmailChanged);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,7 +42,6 @@ public class PoliciesController : Controller
|
|||||||
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
private readonly IDataProtectorTokenFactory<OrgUserInviteTokenable> _orgUserInviteTokenDataFactory;
|
||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
private readonly ISavePolicyCommand _savePolicyCommand;
|
||||||
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
||||||
|
|
||||||
@ -55,7 +54,6 @@ public class PoliciesController : Controller
|
|||||||
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
IDataProtectorTokenFactory<OrgUserInviteTokenable> orgUserInviteTokenDataFactory,
|
||||||
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
IOrganizationHasVerifiedDomainsQuery organizationHasVerifiedDomainsQuery,
|
||||||
IOrganizationRepository organizationRepository,
|
IOrganizationRepository organizationRepository,
|
||||||
IFeatureService featureService,
|
|
||||||
ISavePolicyCommand savePolicyCommand,
|
ISavePolicyCommand savePolicyCommand,
|
||||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||||
{
|
{
|
||||||
@ -69,7 +67,6 @@ public class PoliciesController : Controller
|
|||||||
_organizationRepository = organizationRepository;
|
_organizationRepository = organizationRepository;
|
||||||
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
_orgUserInviteTokenDataFactory = orgUserInviteTokenDataFactory;
|
||||||
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
_organizationHasVerifiedDomainsQuery = organizationHasVerifiedDomainsQuery;
|
||||||
_featureService = featureService;
|
|
||||||
_savePolicyCommand = savePolicyCommand;
|
_savePolicyCommand = savePolicyCommand;
|
||||||
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
||||||
}
|
}
|
||||||
@ -221,9 +218,7 @@ public class PoliciesController : Controller
|
|||||||
{
|
{
|
||||||
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
|
var savePolicyRequest = await model.ToSavePolicyModelAsync(orgId, type, _currentContext);
|
||||||
|
|
||||||
var policy = _featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor) ?
|
var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest);
|
||||||
await _vNextSavePolicyCommand.SaveAsync(savePolicyRequest) :
|
|
||||||
await _savePolicyCommand.VNextSaveAsync(savePolicyRequest);
|
|
||||||
|
|
||||||
return new PolicyResponseModel(policy);
|
return new PolicyResponseModel(policy);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,41 +1,28 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using System.ComponentModel.DataAnnotations;
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||||
using Bit.Core.Models.Data;
|
|
||||||
using Bit.Core.Settings;
|
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
namespace Bit.Api.AdminConsole.Models.Request.Organizations;
|
||||||
|
|
||||||
public class OrganizationUpdateRequestModel
|
public class OrganizationUpdateRequestModel
|
||||||
{
|
{
|
||||||
[Required]
|
|
||||||
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
|
[StringLength(50, ErrorMessage = "The field Name exceeds the maximum length.")]
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
||||||
public string Name { get; set; }
|
public string? Name { get; set; }
|
||||||
[StringLength(50, ErrorMessage = "The field Business Name exceeds the maximum length.")]
|
|
||||||
[JsonConverter(typeof(HtmlEncodingStringConverter))]
|
|
||||||
public string BusinessName { get; set; }
|
|
||||||
[EmailAddress]
|
|
||||||
[Required]
|
|
||||||
[StringLength(256)]
|
|
||||||
public string BillingEmail { get; set; }
|
|
||||||
public Permissions Permissions { get; set; }
|
|
||||||
public OrganizationKeysRequestModel Keys { get; set; }
|
|
||||||
|
|
||||||
public virtual Organization ToOrganization(Organization existingOrganization, GlobalSettings globalSettings)
|
[EmailAddress]
|
||||||
|
[StringLength(256)]
|
||||||
|
public string? BillingEmail { get; set; }
|
||||||
|
|
||||||
|
public OrganizationKeysRequestModel? Keys { get; set; }
|
||||||
|
|
||||||
|
public OrganizationUpdateRequest ToCommandRequest(Guid organizationId) => new()
|
||||||
{
|
{
|
||||||
if (!globalSettings.SelfHosted)
|
OrganizationId = organizationId,
|
||||||
{
|
Name = Name,
|
||||||
// These items come from the license file
|
BillingEmail = BillingEmail,
|
||||||
existingOrganization.Name = Name;
|
PublicKey = Keys?.PublicKey,
|
||||||
existingOrganization.BusinessName = BusinessName;
|
EncryptedPrivateKey = Keys?.EncryptedPrivateKey
|
||||||
existingOrganization.BillingEmail = BillingEmail?.ToLowerInvariant()?.Trim();
|
};
|
||||||
}
|
|
||||||
Keys?.ToOrganization(existingOrganization);
|
|
||||||
return existingOrganization;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,7 +119,7 @@ public class OrganizationUserResetPasswordEnrollmentRequestModel
|
|||||||
|
|
||||||
public class OrganizationUserBulkRequestModel
|
public class OrganizationUserBulkRequestModel
|
||||||
{
|
{
|
||||||
[Required]
|
[Required, MinLength(1)]
|
||||||
public IEnumerable<Guid> Ids { get; set; }
|
public IEnumerable<Guid> Ids { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,6 +47,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
|||||||
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
|
||||||
UseSecretsManager = organizationDetails.UseSecretsManager;
|
UseSecretsManager = organizationDetails.UseSecretsManager;
|
||||||
|
UsePhishingBlocker = organizationDetails.UsePhishingBlocker;
|
||||||
UsePasswordManager = organizationDetails.UsePasswordManager;
|
UsePasswordManager = organizationDetails.UsePasswordManager;
|
||||||
SelfHost = organizationDetails.SelfHost;
|
SelfHost = organizationDetails.SelfHost;
|
||||||
Seats = organizationDetails.Seats;
|
Seats = organizationDetails.Seats;
|
||||||
@ -99,6 +100,7 @@ public abstract class BaseProfileOrganizationResponseModel : ResponseModel
|
|||||||
public bool UseOrganizationDomains { get; set; }
|
public bool UseOrganizationDomains { get; set; }
|
||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
public bool SelfHost { get; set; }
|
public bool SelfHost { get; set; }
|
||||||
public int? Seats { get; set; }
|
public int? Seats { get; set; }
|
||||||
public short? MaxCollections { get; set; }
|
public short? MaxCollections { get; set; }
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
// FIXME: Update this file to be null safe and then delete the line below
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
using System.Security.Claims;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Bit.Api.Models.Response;
|
using Bit.Api.Models.Response;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.Billing.Enums;
|
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.Billing.Organizations.Models;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
using Bit.Core.Models.Business;
|
using Bit.Core.Models.Business;
|
||||||
@ -71,6 +74,7 @@ public class OrganizationResponseModel : ResponseModel
|
|||||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||||
|
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@ -120,6 +124,7 @@ public class OrganizationResponseModel : ResponseModel
|
|||||||
public bool UseOrganizationDomains { get; set; }
|
public bool UseOrganizationDomains { get; set; }
|
||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
||||||
@ -175,6 +180,30 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license, ClaimsPrincipal claimsPrincipal) :
|
||||||
|
this(organization, (Plan)null)
|
||||||
|
{
|
||||||
|
if (license != null)
|
||||||
|
{
|
||||||
|
// CRITICAL: When a license has a Token (JWT), ALWAYS use the expiration from the token claim
|
||||||
|
// The token's expiration is cryptographically secured and cannot be tampered with
|
||||||
|
// The file's Expires property can be manually edited and should NOT be trusted for display
|
||||||
|
if (claimsPrincipal != null)
|
||||||
|
{
|
||||||
|
Expiration = claimsPrincipal.GetValue<DateTime>(OrganizationLicenseConstants.Expires);
|
||||||
|
ExpirationWithoutGracePeriod = claimsPrincipal.GetValue<DateTime?>(OrganizationLicenseConstants.ExpirationWithoutGracePeriod);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No token - use the license file expiration (for older licenses without tokens)
|
||||||
|
Expiration = license.Expires;
|
||||||
|
ExpirationWithoutGracePeriod = license.ExpirationWithoutGracePeriod ?? (license.Trial
|
||||||
|
? license.Expires
|
||||||
|
: license.Expires?.AddDays(-Constants.OrganizationSelfHostSubscriptionGracePeriodDays));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public string StorageName { get; set; }
|
public string StorageName { get; set; }
|
||||||
public double? StorageGb { get; set; }
|
public double? StorageGb { get; set; }
|
||||||
public BillingCustomerDiscount CustomerDiscount { get; set; }
|
public BillingCustomerDiscount CustomerDiscount { get; set; }
|
||||||
|
|||||||
@ -5,15 +5,10 @@ using System.Net;
|
|||||||
using Bit.Api.AdminConsole.Public.Models.Request;
|
using Bit.Api.AdminConsole.Public.Models.Request;
|
||||||
using Bit.Api.AdminConsole.Public.Models.Response;
|
using Bit.Api.AdminConsole.Public.Models.Response;
|
||||||
using Bit.Api.Models.Public.Response;
|
using Bit.Api.Models.Public.Response;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.Entities;
|
|
||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Services;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@ -24,25 +19,16 @@ namespace Bit.Api.AdminConsole.Public.Controllers;
|
|||||||
public class PoliciesController : Controller
|
public class PoliciesController : Controller
|
||||||
{
|
{
|
||||||
private readonly IPolicyRepository _policyRepository;
|
private readonly IPolicyRepository _policyRepository;
|
||||||
private readonly IPolicyService _policyService;
|
|
||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IFeatureService _featureService;
|
|
||||||
private readonly ISavePolicyCommand _savePolicyCommand;
|
|
||||||
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
private readonly IVNextSavePolicyCommand _vNextSavePolicyCommand;
|
||||||
|
|
||||||
public PoliciesController(
|
public PoliciesController(
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IPolicyService policyService,
|
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService,
|
|
||||||
ISavePolicyCommand savePolicyCommand,
|
|
||||||
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
IVNextSavePolicyCommand vNextSavePolicyCommand)
|
||||||
{
|
{
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_policyService = policyService;
|
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_featureService = featureService;
|
|
||||||
_savePolicyCommand = savePolicyCommand;
|
|
||||||
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
_vNextSavePolicyCommand = vNextSavePolicyCommand;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,17 +83,8 @@ public class PoliciesController : Controller
|
|||||||
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
[ProducesResponseType((int)HttpStatusCode.NotFound)]
|
||||||
public async Task<IActionResult> Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model)
|
public async Task<IActionResult> Put(PolicyType type, [FromBody] PolicyUpdateRequestModel model)
|
||||||
{
|
{
|
||||||
Policy policy;
|
var savePolicyModel = model.ToSavePolicyModel(_currentContext.OrganizationId!.Value, type);
|
||||||
if (_featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
|
var policy = await _vNextSavePolicyCommand.SaveAsync(savePolicyModel);
|
||||||
{
|
|
||||||
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 response = new PolicyResponseModel(policy);
|
var response = new PolicyResponseModel(policy);
|
||||||
return new JsonResult(response);
|
return new JsonResult(response);
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="8.0.2" />
|
||||||
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
<PackageReference Include="AspNetCore.HealthChecks.Uris" Version="8.0.1" />
|
||||||
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.25.0" />
|
<PackageReference Include="Azure.Messaging.EventGrid" Version="4.31.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,8 @@ public class AccountsController(
|
|||||||
IUserService userService,
|
IUserService userService,
|
||||||
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
|
||||||
IUserAccountKeysQuery userAccountKeysQuery,
|
IUserAccountKeysQuery userAccountKeysQuery,
|
||||||
IFeatureService featureService) : Controller
|
IFeatureService featureService,
|
||||||
|
ILicensingService licensingService) : Controller
|
||||||
{
|
{
|
||||||
[HttpPost("premium")]
|
[HttpPost("premium")]
|
||||||
public async Task<PaymentResponseModel> PostPremiumAsync(
|
public async Task<PaymentResponseModel> PostPremiumAsync(
|
||||||
@ -97,12 +98,14 @@ public class AccountsController(
|
|||||||
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
var includeMilestone2Discount = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||||
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
var subscriptionInfo = await paymentService.GetSubscriptionAsync(user);
|
||||||
var license = await userService.GenerateLicenseAsync(user, subscriptionInfo);
|
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
|
else
|
||||||
{
|
{
|
||||||
var license = await userService.GenerateLicenseAsync(user);
|
var license = await userService.GenerateLicenseAsync(user);
|
||||||
return new SubscriptionResponseModel(user, license);
|
var claimsPrincipal = licensingService.GetClaimsPrincipalFromLicense(license);
|
||||||
|
return new SubscriptionResponseModel(user, null, license, claimsPrincipal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|||||||
@ -67,7 +67,8 @@ public class OrganizationsController(
|
|||||||
if (globalSettings.SelfHosted)
|
if (globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
var orgLicense = await licensingService.ReadOrganizationLicenseAsync(organization);
|
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);
|
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||||
|
|||||||
@ -66,7 +66,10 @@ public class HibpController : Controller
|
|||||||
}
|
}
|
||||||
else if (response.StatusCode == HttpStatusCode.NotFound)
|
else if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
{
|
{
|
||||||
return new NotFoundResult();
|
/* 12/1/2025 - Per the HIBP API, If the domain does not have any email addresses in any breaches,
|
||||||
|
an HTTP 404 response will be returned. API also specifies that "404 Not found is the account could
|
||||||
|
not be found and has therefore not been pwned". Per REST semantics we will return 200 OK with empty array. */
|
||||||
|
return Content("[]", "application/json");
|
||||||
}
|
}
|
||||||
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
|
else if (response.StatusCode == HttpStatusCode.TooManyRequests && retry)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -84,7 +84,7 @@ public class AccountsKeyManagementController : Controller
|
|||||||
[HttpPost("key-management/regenerate-keys")]
|
[HttpPost("key-management/regenerate-keys")]
|
||||||
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
|
public async Task RegenerateKeysAsync([FromBody] KeyRegenerationRequestModel request)
|
||||||
{
|
{
|
||||||
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration))
|
if (!_featureService.IsEnabled(FeatureFlagKeys.PrivateKeyRegeneration) && !_featureService.IsEnabled(FeatureFlagKeys.DataRecoveryTool))
|
||||||
{
|
{
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bit.Core.KeyManagement.Models.Api.Request;
|
||||||
|
|
||||||
namespace Bit.Api.KeyManagement.Models.Requests;
|
namespace Bit.Api.KeyManagement.Models.Requests;
|
||||||
|
|
||||||
|
|||||||
@ -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.Billing.Models.Business;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Api;
|
using Bit.Core.Models.Api;
|
||||||
@ -37,6 +40,46 @@ public class SubscriptionResponseModel : ResponseModel
|
|||||||
: null;
|
: 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)
|
public SubscriptionResponseModel(User user, UserLicense? license = null)
|
||||||
: base("subscription")
|
: base("subscription")
|
||||||
{
|
{
|
||||||
|
|||||||
337
src/Api/SecretsManager/Controllers/SecretVersionsController.cs
Normal file
337
src/Api/SecretsManager/Controllers/SecretVersionsController.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity;
|
|||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.SecretsManager.AuthorizationRequirements;
|
using Bit.Core.SecretsManager.AuthorizationRequirements;
|
||||||
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
|
||||||
using Bit.Core.SecretsManager.Entities;
|
using Bit.Core.SecretsManager.Entities;
|
||||||
@ -29,6 +30,7 @@ public class SecretsController : Controller
|
|||||||
private readonly ICurrentContext _currentContext;
|
private readonly ICurrentContext _currentContext;
|
||||||
private readonly IProjectRepository _projectRepository;
|
private readonly IProjectRepository _projectRepository;
|
||||||
private readonly ISecretRepository _secretRepository;
|
private readonly ISecretRepository _secretRepository;
|
||||||
|
private readonly ISecretVersionRepository _secretVersionRepository;
|
||||||
private readonly ICreateSecretCommand _createSecretCommand;
|
private readonly ICreateSecretCommand _createSecretCommand;
|
||||||
private readonly IUpdateSecretCommand _updateSecretCommand;
|
private readonly IUpdateSecretCommand _updateSecretCommand;
|
||||||
private readonly IDeleteSecretCommand _deleteSecretCommand;
|
private readonly IDeleteSecretCommand _deleteSecretCommand;
|
||||||
@ -38,11 +40,13 @@ public class SecretsController : Controller
|
|||||||
private readonly IUserService _userService;
|
private readonly IUserService _userService;
|
||||||
private readonly IEventService _eventService;
|
private readonly IEventService _eventService;
|
||||||
private readonly IAuthorizationService _authorizationService;
|
private readonly IAuthorizationService _authorizationService;
|
||||||
|
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||||
|
|
||||||
public SecretsController(
|
public SecretsController(
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IProjectRepository projectRepository,
|
IProjectRepository projectRepository,
|
||||||
ISecretRepository secretRepository,
|
ISecretRepository secretRepository,
|
||||||
|
ISecretVersionRepository secretVersionRepository,
|
||||||
ICreateSecretCommand createSecretCommand,
|
ICreateSecretCommand createSecretCommand,
|
||||||
IUpdateSecretCommand updateSecretCommand,
|
IUpdateSecretCommand updateSecretCommand,
|
||||||
IDeleteSecretCommand deleteSecretCommand,
|
IDeleteSecretCommand deleteSecretCommand,
|
||||||
@ -51,11 +55,13 @@ public class SecretsController : Controller
|
|||||||
ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery,
|
ISecretAccessPoliciesUpdatesQuery secretAccessPoliciesUpdatesQuery,
|
||||||
IUserService userService,
|
IUserService userService,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IAuthorizationService authorizationService)
|
IAuthorizationService authorizationService,
|
||||||
|
IOrganizationUserRepository organizationUserRepository)
|
||||||
{
|
{
|
||||||
_currentContext = currentContext;
|
_currentContext = currentContext;
|
||||||
_projectRepository = projectRepository;
|
_projectRepository = projectRepository;
|
||||||
_secretRepository = secretRepository;
|
_secretRepository = secretRepository;
|
||||||
|
_secretVersionRepository = secretVersionRepository;
|
||||||
_createSecretCommand = createSecretCommand;
|
_createSecretCommand = createSecretCommand;
|
||||||
_updateSecretCommand = updateSecretCommand;
|
_updateSecretCommand = updateSecretCommand;
|
||||||
_deleteSecretCommand = deleteSecretCommand;
|
_deleteSecretCommand = deleteSecretCommand;
|
||||||
@ -65,6 +71,7 @@ public class SecretsController : Controller
|
|||||||
_userService = userService;
|
_userService = userService;
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
_authorizationService = authorizationService;
|
_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);
|
var result = await _updateSecretCommand.UpdateAsync(updatedSecret, accessPoliciesUpdates);
|
||||||
await LogSecretEventAsync(secret, EventType.Secret_Edited);
|
await LogSecretEventAsync(secret, EventType.Secret_Edited);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,9 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace Bit.Api.SecretsManager.Models.Request;
|
||||||
|
|
||||||
|
public class RestoreSecretVersionRequestModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public Guid VersionId { get; set; }
|
||||||
|
}
|
||||||
@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject
|
|||||||
|
|
||||||
public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; }
|
public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; }
|
||||||
|
|
||||||
|
public bool ValueChanged { get; set; } = false;
|
||||||
|
|
||||||
public Secret ToSecret(Secret secret)
|
public Secret ToSecret(Secret secret)
|
||||||
{
|
{
|
||||||
secret.Key = Key;
|
secret.Key = Key;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -216,7 +216,7 @@ public class Startup
|
|||||||
config.Conventions.Add(new PublicApiControllersModelConvention());
|
config.Conventions.Add(new PublicApiControllersModelConvention());
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddSwagger(globalSettings, Environment);
|
services.AddSwaggerGen(globalSettings, Environment);
|
||||||
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
|
Jobs.JobsHostedService.AddJobsServices(services, globalSettings.SelfHosted);
|
||||||
services.AddHostedService<Jobs.JobsHostedService>();
|
services.AddHostedService<Jobs.JobsHostedService>();
|
||||||
|
|
||||||
@ -226,7 +226,8 @@ public class Startup
|
|||||||
services.AddHostedService<Core.HostedServices.ApplicationCacheHostedService>();
|
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.AddSlackService(globalSettings);
|
||||||
services.AddTeamsService(globalSettings);
|
services.AddTeamsService(globalSettings);
|
||||||
}
|
}
|
||||||
@ -292,17 +293,59 @@ public class Startup
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add Swagger
|
// Add Swagger
|
||||||
|
// Note that the swagger.json generation is configured in the call to AddSwaggerGen above.
|
||||||
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
|
if (Environment.IsDevelopment() || globalSettings.SelfHosted)
|
||||||
{
|
{
|
||||||
|
// adds the middleware to serve the swagger.json while the server is running
|
||||||
app.UseSwagger(config =>
|
app.UseSwagger(config =>
|
||||||
{
|
{
|
||||||
config.RouteTemplate = "specs/{documentName}/swagger.json";
|
config.RouteTemplate = "specs/{documentName}/swagger.json";
|
||||||
|
|
||||||
|
// Remove all Bitwarden cloud servers and only register the local server
|
||||||
config.PreSerializeFilters.Add((swaggerDoc, httpReq) =>
|
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 =>
|
app.UseSwaggerUI(config =>
|
||||||
{
|
{
|
||||||
config.DocumentTitle = "Bitwarden API Documentation";
|
config.DocumentTitle = "Bitwarden API Documentation";
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
using Bit.Api.AdminConsole.Authorization;
|
using Bit.Api.AdminConsole.Authorization;
|
||||||
using Bit.Api.Tools.Authorization;
|
using Bit.Api.Tools.Authorization;
|
||||||
using Bit.Core.Auth.IdentityServer;
|
|
||||||
using Bit.Core.PhishingDomainFeatures;
|
using Bit.Core.PhishingDomainFeatures;
|
||||||
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
using Bit.Core.PhishingDomainFeatures.Interfaces;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -10,6 +9,7 @@ using Bit.Core.Utilities;
|
|||||||
using Bit.Core.Vault.Authorization.SecurityTasks;
|
using Bit.Core.Vault.Authorization.SecurityTasks;
|
||||||
using Bit.SharedWeb.Health;
|
using Bit.SharedWeb.Health;
|
||||||
using Bit.SharedWeb.Swagger;
|
using Bit.SharedWeb.Swagger;
|
||||||
|
using Bit.SharedWeb.Utilities;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.OpenApi.Models;
|
using Microsoft.OpenApi.Models;
|
||||||
|
|
||||||
@ -17,7 +17,10 @@ namespace Bit.Api.Utilities;
|
|||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
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 =>
|
services.AddSwaggerGen(config =>
|
||||||
{
|
{
|
||||||
@ -36,6 +39,8 @@ public static class ServiceCollectionExtensions
|
|||||||
organizations tools for managing members, collections, groups, event logs, and policies.
|
organizations tools for managing members, collections, groups, event logs, and policies.
|
||||||
If you are looking for the Vault Management API, refer instead to
|
If you are looking for the Vault Management API, refer instead to
|
||||||
[this document](https://bitwarden.com/help/vault-management-api/).
|
[this document](https://bitwarden.com/help/vault-management-api/).
|
||||||
|
|
||||||
|
**Note:** your authorization must match the server you have selected.
|
||||||
""",
|
""",
|
||||||
License = new OpenApiLicense
|
License = new OpenApiLicense
|
||||||
{
|
{
|
||||||
@ -46,36 +51,20 @@ public static class ServiceCollectionExtensions
|
|||||||
|
|
||||||
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
|
config.SwaggerDoc("internal", new OpenApiInfo { Title = "Bitwarden Internal API", Version = "latest" });
|
||||||
|
|
||||||
config.AddSecurityDefinition("oauth2-client-credentials", new OpenApiSecurityScheme
|
// 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
|
||||||
Type = SecuritySchemeType.OAuth2,
|
// or dev mode (see Api Startup.cs).
|
||||||
Flows = new OpenApiOAuthFlows
|
config.AddSwaggerServerWithSecurity(
|
||||||
{
|
serverId: "US_server",
|
||||||
ClientCredentials = new OpenApiOAuthFlow
|
serverUrl: "https://api.bitwarden.com",
|
||||||
{
|
identityTokenUrl: "https://identity.bitwarden.com/connect/token",
|
||||||
TokenUrl = new Uri($"{globalSettings.BaseServiceUri.Identity}/connect/token"),
|
serverDescription: "US server");
|
||||||
Scopes = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ ApiScopes.ApiOrganization, "Organization APIs" },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
config.AddSecurityRequirement(new OpenApiSecurityRequirement
|
config.AddSwaggerServerWithSecurity(
|
||||||
{
|
serverId: "EU_server",
|
||||||
{
|
serverUrl: "https://api.bitwarden.eu",
|
||||||
new OpenApiSecurityScheme
|
identityTokenUrl: "https://identity.bitwarden.eu/connect/token",
|
||||||
{
|
serverDescription: "EU server");
|
||||||
Reference = new OpenApiReference
|
|
||||||
{
|
|
||||||
Type = ReferenceType.SecurityScheme,
|
|
||||||
Id = "oauth2-client-credentials"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new[] { ApiScopes.ApiOrganization }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
config.DescribeAllParametersInCamelCase();
|
config.DescribeAllParametersInCamelCase();
|
||||||
// config.UseReferencedDefinitionsForEnums();
|
// config.UseReferencedDefinitionsForEnums();
|
||||||
|
|||||||
@ -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);
|
ValidateClientVersionForFido2CredentialSupport(cipher);
|
||||||
|
|
||||||
var original = cipher.Clone();
|
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);
|
_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.");
|
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?)>();
|
var shareCiphers = new List<(CipherDetails, DateTime?)>();
|
||||||
@ -1288,11 +1278,6 @@ public class CiphersController : Controller
|
|||||||
|
|
||||||
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
ValidateClientVersionForFido2CredentialSupport(existingCipher);
|
||||||
|
|
||||||
if (existingCipher.ArchivedDate.HasValue)
|
|
||||||
{
|
|
||||||
throw new BadRequestException("Cannot move archived items to an organization.");
|
|
||||||
}
|
|
||||||
|
|
||||||
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
|
shareCiphers.Add((cipher.ToCipherDetails(existingCipher), cipher.LastKnownRevisionDate));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
36
src/Billing/Controllers/JobsController.cs
Normal file
36
src/Billing/Controllers/JobsController.cs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
using Bit.Billing.Jobs;
|
||||||
|
using Bit.Core.Utilities;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bit.Billing.Controllers;
|
||||||
|
|
||||||
|
[Route("jobs")]
|
||||||
|
[SelfHosted(NotSelfHostedOnly = true)]
|
||||||
|
[RequireLowerEnvironment]
|
||||||
|
public class JobsController(
|
||||||
|
JobsHostedService jobsHostedService) : Controller
|
||||||
|
{
|
||||||
|
[HttpPost("run/{jobName}")]
|
||||||
|
public async Task<IActionResult> RunJobAsync(string jobName)
|
||||||
|
{
|
||||||
|
if (jobName == nameof(ReconcileAdditionalStorageJob))
|
||||||
|
{
|
||||||
|
await jobsHostedService.RunJobAdHocAsync<ReconcileAdditionalStorageJob>();
|
||||||
|
return Ok(new { message = $"Job {jobName} scheduled successfully" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new { error = $"Unknown job name: {jobName}" });
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stop/{jobName}")]
|
||||||
|
public async Task<IActionResult> StopJobAsync(string jobName)
|
||||||
|
{
|
||||||
|
if (jobName == nameof(ReconcileAdditionalStorageJob))
|
||||||
|
{
|
||||||
|
await jobsHostedService.InterruptAdHocJobAsync<ReconcileAdditionalStorageJob>();
|
||||||
|
return Ok(new { message = $"Job {jobName} queued for cancellation" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return BadRequest(new { error = $"Unknown job name: {jobName}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,4 +10,13 @@ public class AliveJob(ILogger<AliveJob> logger) : BaseJob(logger)
|
|||||||
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
|
_logger.LogInformation(Core.Constants.BypassFiltersEventId, null, "Billing service is alive!");
|
||||||
return Task.FromResult(0);
|
return Task.FromResult(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static ITrigger GetTrigger()
|
||||||
|
{
|
||||||
|
return TriggerBuilder.Create()
|
||||||
|
.WithIdentity("EveryTopOfTheHourTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithCronSchedule("0 0 * * * ?")
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,27 @@
|
|||||||
using Bit.Core.Jobs;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Jobs;
|
||||||
using Bit.Core.Settings;
|
using Bit.Core.Settings;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
|
|
||||||
namespace Bit.Billing.Jobs;
|
namespace Bit.Billing.Jobs;
|
||||||
|
|
||||||
public class JobsHostedService : BaseJobsHostedService
|
public class JobsHostedService(
|
||||||
|
GlobalSettings globalSettings,
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<JobsHostedService> logger,
|
||||||
|
ILogger<JobListener> listenerLogger,
|
||||||
|
ISchedulerFactory schedulerFactory)
|
||||||
|
: BaseJobsHostedService(globalSettings, serviceProvider, logger, listenerLogger)
|
||||||
{
|
{
|
||||||
public JobsHostedService(
|
private List<JobKey> AdHocJobKeys { get; } = [];
|
||||||
GlobalSettings globalSettings,
|
private IScheduler? _adHocScheduler;
|
||||||
IServiceProvider serviceProvider,
|
|
||||||
ILogger<JobsHostedService> logger,
|
|
||||||
ILogger<JobListener> listenerLogger)
|
|
||||||
: base(globalSettings, serviceProvider, logger, listenerLogger) { }
|
|
||||||
|
|
||||||
public override async Task StartAsync(CancellationToken cancellationToken)
|
public override async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var everyTopOfTheHourTrigger = TriggerBuilder.Create()
|
|
||||||
.WithIdentity("EveryTopOfTheHourTrigger")
|
|
||||||
.StartNow()
|
|
||||||
.WithCronSchedule("0 0 * * * ?")
|
|
||||||
.Build();
|
|
||||||
|
|
||||||
Jobs = new List<Tuple<Type, ITrigger>>
|
Jobs = new List<Tuple<Type, ITrigger>>
|
||||||
{
|
{
|
||||||
new Tuple<Type, ITrigger>(typeof(AliveJob), everyTopOfTheHourTrigger)
|
new(typeof(AliveJob), AliveJob.GetTrigger()),
|
||||||
|
new(typeof(ReconcileAdditionalStorageJob), ReconcileAdditionalStorageJob.GetTrigger())
|
||||||
};
|
};
|
||||||
|
|
||||||
await base.StartAsync(cancellationToken);
|
await base.StartAsync(cancellationToken);
|
||||||
@ -33,5 +31,54 @@ public class JobsHostedService : BaseJobsHostedService
|
|||||||
{
|
{
|
||||||
services.AddTransient<AliveJob>();
|
services.AddTransient<AliveJob>();
|
||||||
services.AddTransient<SubscriptionCancellationJob>();
|
services.AddTransient<SubscriptionCancellationJob>();
|
||||||
|
services.AddTransient<ReconcileAdditionalStorageJob>();
|
||||||
|
// add this service as a singleton so we can inject it where needed
|
||||||
|
services.AddSingleton<JobsHostedService>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<JobsHostedService>());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InterruptAdHocJobAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
|
||||||
|
{
|
||||||
|
if (_adHocScheduler == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("AdHocScheduler is null, cannot interrupt ad-hoc job.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobKey = AdHocJobKeys.FirstOrDefault(j => j.Name == typeof(T).ToString());
|
||||||
|
if (jobKey == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException($"Cannot find job key: {typeof(T)}, not running?");
|
||||||
|
}
|
||||||
|
logger.LogInformation("CANCELLING ad-hoc job with key: {JobKey}", jobKey);
|
||||||
|
AdHocJobKeys.Remove(jobKey);
|
||||||
|
await _adHocScheduler.Interrupt(jobKey, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RunJobAdHocAsync<T>(CancellationToken cancellationToken = default) where T : class, IJob
|
||||||
|
{
|
||||||
|
_adHocScheduler ??= await schedulerFactory.GetScheduler(cancellationToken);
|
||||||
|
|
||||||
|
var jobKey = new JobKey(typeof(T).ToString());
|
||||||
|
|
||||||
|
var currentlyExecuting = await _adHocScheduler.GetCurrentlyExecutingJobs(cancellationToken);
|
||||||
|
if (currentlyExecuting.Any(j => j.JobDetail.Key.Equals(jobKey)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Job {jobKey} is already running");
|
||||||
|
}
|
||||||
|
|
||||||
|
AdHocJobKeys.Add(jobKey);
|
||||||
|
|
||||||
|
var job = JobBuilder.Create<T>()
|
||||||
|
.WithIdentity(jobKey)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var trigger = TriggerBuilder.Create()
|
||||||
|
.WithIdentity(typeof(T).ToString())
|
||||||
|
.StartNow()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
logger.LogInformation("Scheduling ad-hoc job with key: {JobKey}", jobKey);
|
||||||
|
|
||||||
|
await _adHocScheduler.ScheduleJob(job, trigger, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
207
src/Billing/Jobs/ReconcileAdditionalStorageJob.cs
Normal file
207
src/Billing/Jobs/ReconcileAdditionalStorageJob.cs
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Bit.Billing.Services;
|
||||||
|
using Bit.Core;
|
||||||
|
using Bit.Core.Billing.Constants;
|
||||||
|
using Bit.Core.Jobs;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Quartz;
|
||||||
|
using Stripe;
|
||||||
|
|
||||||
|
namespace Bit.Billing.Jobs;
|
||||||
|
|
||||||
|
public class ReconcileAdditionalStorageJob(
|
||||||
|
IStripeFacade stripeFacade,
|
||||||
|
ILogger<ReconcileAdditionalStorageJob> logger,
|
||||||
|
IFeatureService featureService) : BaseJob(logger)
|
||||||
|
{
|
||||||
|
private const string _storageGbMonthlyPriceId = "storage-gb-monthly";
|
||||||
|
private const string _storageGbAnnuallyPriceId = "storage-gb-annually";
|
||||||
|
private const string _personalStorageGbAnnuallyPriceId = "personal-storage-gb-annually";
|
||||||
|
private const int _storageGbToRemove = 4;
|
||||||
|
|
||||||
|
protected override async Task ExecuteJobAsync(IJobExecutionContext context)
|
||||||
|
{
|
||||||
|
if (!featureService.IsEnabled(FeatureFlagKeys.PM28265_EnableReconcileAdditionalStorageJob))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipping ReconcileAdditionalStorageJob, feature flag off.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var liveMode = featureService.IsEnabled(FeatureFlagKeys.PM28265_ReconcileAdditionalStorageJobEnableLiveMode);
|
||||||
|
|
||||||
|
// Execution tracking
|
||||||
|
var subscriptionsFound = 0;
|
||||||
|
var subscriptionsUpdated = 0;
|
||||||
|
var subscriptionsWithErrors = 0;
|
||||||
|
var failures = new List<string>();
|
||||||
|
|
||||||
|
logger.LogInformation("Starting ReconcileAdditionalStorageJob (live mode: {LiveMode})", liveMode);
|
||||||
|
|
||||||
|
var priceIds = new[] { _storageGbMonthlyPriceId, _storageGbAnnuallyPriceId, _personalStorageGbAnnuallyPriceId };
|
||||||
|
|
||||||
|
foreach (var priceId in priceIds)
|
||||||
|
{
|
||||||
|
var options = new SubscriptionListOptions
|
||||||
|
{
|
||||||
|
Limit = 100,
|
||||||
|
Status = StripeConstants.SubscriptionStatus.Active,
|
||||||
|
Price = priceId
|
||||||
|
};
|
||||||
|
|
||||||
|
await foreach (var subscription in stripeFacade.ListSubscriptionsAutoPagingAsync(options))
|
||||||
|
{
|
||||||
|
if (context.CancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogWarning(
|
||||||
|
"Job cancelled!! Exiting. Progress at time of cancellation: Subscriptions found: {SubscriptionsFound}, " +
|
||||||
|
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||||
|
subscriptionsFound,
|
||||||
|
liveMode
|
||||||
|
? subscriptionsUpdated
|
||||||
|
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||||
|
subscriptionsWithErrors,
|
||||||
|
failures.Count > 0
|
||||||
|
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||||
|
: string.Empty
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription == null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation("Processing subscription: {SubscriptionId}", subscription.Id);
|
||||||
|
subscriptionsFound++;
|
||||||
|
|
||||||
|
if (subscription.Metadata?.TryGetValue(StripeConstants.MetadataKeys.StorageReconciled2025, out var dateString) == true)
|
||||||
|
{
|
||||||
|
if (DateTime.TryParse(dateString, null, DateTimeStyles.RoundtripKind, out var dateProcessed))
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipping subscription {SubscriptionId} - already processed on {Date}",
|
||||||
|
subscription.Id,
|
||||||
|
dateProcessed.ToString("f"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOptions = BuildSubscriptionUpdateOptions(subscription, priceId);
|
||||||
|
|
||||||
|
if (updateOptions == null)
|
||||||
|
{
|
||||||
|
logger.LogInformation("Skipping subscription {SubscriptionId} - no updates needed", subscription.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionsUpdated++;
|
||||||
|
|
||||||
|
if (!liveMode)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Not live mode (dry-run): Would have updated subscription {SubscriptionId} with item changes: {NewLine}{UpdateOptions}",
|
||||||
|
subscription.Id,
|
||||||
|
Environment.NewLine,
|
||||||
|
JsonSerializer.Serialize(updateOptions));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await stripeFacade.UpdateSubscription(subscription.Id, updateOptions);
|
||||||
|
logger.LogInformation("Successfully updated subscription: {SubscriptionId}", subscription.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
subscriptionsWithErrors++;
|
||||||
|
failures.Add($"Subscription {subscription.Id}: {ex.Message}");
|
||||||
|
logger.LogError(ex, "Failed to update subscription {SubscriptionId}: {ErrorMessage}",
|
||||||
|
subscription.Id, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.LogInformation(
|
||||||
|
"ReconcileAdditionalStorageJob completed. Subscriptions found: {SubscriptionsFound}, " +
|
||||||
|
"Updated: {SubscriptionsUpdated}, Errors: {SubscriptionsWithErrors}{Failures}",
|
||||||
|
subscriptionsFound,
|
||||||
|
liveMode
|
||||||
|
? subscriptionsUpdated
|
||||||
|
: $"(In live mode, would have updated) {subscriptionsUpdated}",
|
||||||
|
subscriptionsWithErrors,
|
||||||
|
failures.Count > 0
|
||||||
|
? $", Failures: {Environment.NewLine}{string.Join(Environment.NewLine, failures)}"
|
||||||
|
: string.Empty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SubscriptionUpdateOptions? BuildSubscriptionUpdateOptions(
|
||||||
|
Subscription subscription,
|
||||||
|
string targetPriceId)
|
||||||
|
{
|
||||||
|
if (subscription.Items?.Data == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateOptions = new SubscriptionUpdateOptions
|
||||||
|
{
|
||||||
|
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
|
||||||
|
Metadata = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[StripeConstants.MetadataKeys.StorageReconciled2025] = DateTime.UtcNow.ToString("o")
|
||||||
|
},
|
||||||
|
Items = []
|
||||||
|
};
|
||||||
|
|
||||||
|
var hasUpdates = false;
|
||||||
|
|
||||||
|
foreach (var item in subscription.Items.Data.Where(item => item?.Price?.Id == targetPriceId))
|
||||||
|
{
|
||||||
|
hasUpdates = true;
|
||||||
|
var currentQuantity = item.Quantity;
|
||||||
|
|
||||||
|
if (currentQuantity > _storageGbToRemove)
|
||||||
|
{
|
||||||
|
var newQuantity = currentQuantity - _storageGbToRemove;
|
||||||
|
logger.LogInformation(
|
||||||
|
"Subscription {SubscriptionId}: reducing quantity from {CurrentQuantity} to {NewQuantity} for price {PriceId}",
|
||||||
|
subscription.Id,
|
||||||
|
currentQuantity,
|
||||||
|
newQuantity,
|
||||||
|
item.Price.Id);
|
||||||
|
|
||||||
|
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Quantity = newQuantity
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.LogInformation("Subscription {SubscriptionId}: deleting storage item with quantity {CurrentQuantity} for price {PriceId}",
|
||||||
|
subscription.Id,
|
||||||
|
currentQuantity,
|
||||||
|
item.Price.Id);
|
||||||
|
|
||||||
|
updateOptions.Items.Add(new SubscriptionItemOptions
|
||||||
|
{
|
||||||
|
Id = item.Id,
|
||||||
|
Deleted = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUpdates ? updateOptions : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ITrigger GetTrigger()
|
||||||
|
{
|
||||||
|
return TriggerBuilder.Create()
|
||||||
|
.WithIdentity("EveryMorningTrigger")
|
||||||
|
.StartNow()
|
||||||
|
.WithCronSchedule("0 0 16 * * ?") // 10am CST daily; the pods execute in UTC time
|
||||||
|
.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,17 @@
|
|||||||
// FIXME: Update this file to be null safe and then delete the line below
|
using Bit.Billing.Services;
|
||||||
#nullable disable
|
using Bit.Core.Billing.Constants;
|
||||||
|
|
||||||
using Bit.Billing.Services;
|
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Quartz;
|
using Quartz;
|
||||||
using Stripe;
|
using Stripe;
|
||||||
|
|
||||||
namespace Bit.Billing.Jobs;
|
namespace Bit.Billing.Jobs;
|
||||||
|
|
||||||
|
using static StripeConstants;
|
||||||
|
|
||||||
public class SubscriptionCancellationJob(
|
public class SubscriptionCancellationJob(
|
||||||
IStripeFacade stripeFacade,
|
IStripeFacade stripeFacade,
|
||||||
IOrganizationRepository organizationRepository)
|
IOrganizationRepository organizationRepository,
|
||||||
|
ILogger<SubscriptionCancellationJob> logger)
|
||||||
: IJob
|
: IJob
|
||||||
{
|
{
|
||||||
public async Task Execute(IJobExecutionContext context)
|
public async Task Execute(IJobExecutionContext context)
|
||||||
@ -21,20 +22,31 @@ public class SubscriptionCancellationJob(
|
|||||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||||
if (organization == null || organization.Enabled)
|
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
|
// Organization was deleted or re-enabled by CS, skip cancellation
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var subscription = await stripeFacade.GetSubscription(subscriptionId);
|
var subscription = await stripeFacade.GetSubscription(subscriptionId, new SubscriptionGetOptions
|
||||||
if (subscription?.Status != "unpaid" ||
|
|
||||||
subscription.LatestInvoice?.BillingReason is not ("subscription_cycle" or "subscription_create"))
|
|
||||||
{
|
{
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cancel the subscription
|
// Cancel the subscription
|
||||||
await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
|
await stripeFacade.CancelSubscription(subscriptionId, new SubscriptionCancelOptions());
|
||||||
|
|
||||||
|
logger.LogInformation("{Job} cancelled subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), subscriptionId);
|
||||||
|
|
||||||
// Void any open invoices
|
// Void any open invoices
|
||||||
var options = new InvoiceListOptions
|
var options = new InvoiceListOptions
|
||||||
{
|
{
|
||||||
@ -46,6 +58,7 @@ public class SubscriptionCancellationJob(
|
|||||||
foreach (var invoice in invoices)
|
foreach (var invoice in invoices)
|
||||||
{
|
{
|
||||||
await stripeFacade.VoidInvoice(invoice.Id);
|
await stripeFacade.VoidInvoice(invoice.Id);
|
||||||
|
logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
while (invoices.HasMore)
|
while (invoices.HasMore)
|
||||||
@ -55,6 +68,7 @@ public class SubscriptionCancellationJob(
|
|||||||
foreach (var invoice in invoices)
|
foreach (var invoice in invoices)
|
||||||
{
|
{
|
||||||
await stripeFacade.VoidInvoice(invoice.Id);
|
await stripeFacade.VoidInvoice(invoice.Id);
|
||||||
|
logger.LogInformation("{Job} voided invoice ({InvoiceID}) for subscription ({SubscriptionID})", nameof(SubscriptionCancellationJob), invoice.Id, subscriptionId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -78,6 +78,11 @@ public interface IStripeFacade
|
|||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
|
||||||
|
SubscriptionListOptions options = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<Subscription> GetSubscription(
|
Task<Subscription> GetSubscription(
|
||||||
string subscriptionId,
|
string subscriptionId,
|
||||||
SubscriptionGetOptions subscriptionGetOptions = null,
|
SubscriptionGetOptions subscriptionGetOptions = null,
|
||||||
@ -111,4 +116,10 @@ public interface IStripeFacade
|
|||||||
TestClockGetOptions testClockGetOptions = null,
|
TestClockGetOptions testClockGetOptions = null,
|
||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<Coupon> GetCoupon(
|
||||||
|
string couponId,
|
||||||
|
CouponGetOptions couponGetOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ public class StripeFacade : IStripeFacade
|
|||||||
private readonly DiscountService _discountService = new();
|
private readonly DiscountService _discountService = new();
|
||||||
private readonly SetupIntentService _setupIntentService = new();
|
private readonly SetupIntentService _setupIntentService = new();
|
||||||
private readonly TestClockService _testClockService = new();
|
private readonly TestClockService _testClockService = new();
|
||||||
|
private readonly CouponService _couponService = new();
|
||||||
|
|
||||||
public async Task<Charge> GetCharge(
|
public async Task<Charge> GetCharge(
|
||||||
string chargeId,
|
string chargeId,
|
||||||
@ -98,6 +99,12 @@ public class StripeFacade : IStripeFacade
|
|||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
|
await _subscriptionService.ListAsync(options, requestOptions, cancellationToken);
|
||||||
|
|
||||||
|
public IAsyncEnumerable<Subscription> ListSubscriptionsAutoPagingAsync(
|
||||||
|
SubscriptionListOptions options = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_subscriptionService.ListAutoPagingAsync(options, requestOptions, cancellationToken);
|
||||||
|
|
||||||
public async Task<Subscription> GetSubscription(
|
public async Task<Subscription> GetSubscription(
|
||||||
string subscriptionId,
|
string subscriptionId,
|
||||||
SubscriptionGetOptions subscriptionGetOptions = null,
|
SubscriptionGetOptions subscriptionGetOptions = null,
|
||||||
@ -137,4 +144,11 @@ public class StripeFacade : IStripeFacade
|
|||||||
RequestOptions requestOptions = null,
|
RequestOptions requestOptions = null,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
|
_testClockService.GetAsync(testClockId, testClockGetOptions, requestOptions, cancellationToken);
|
||||||
|
|
||||||
|
public Task<Coupon> GetCoupon(
|
||||||
|
string couponId,
|
||||||
|
CouponGetOptions couponGetOptions = null,
|
||||||
|
RequestOptions requestOptions = null,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
_couponService.GetAsync(couponId, couponGetOptions, requestOptions, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
using System.Globalization;
|
using Bit.Billing.Constants;
|
||||||
using Bit.Billing.Constants;
|
|
||||||
using Bit.Billing.Jobs;
|
using Bit.Billing.Jobs;
|
||||||
using Bit.Core;
|
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.AdminConsole.Services;
|
using Bit.Core.AdminConsole.Services;
|
||||||
@ -134,11 +132,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
}
|
}
|
||||||
case StripeSubscriptionStatus.Active when providerId.HasValue:
|
case StripeSubscriptionStatus.Active when providerId.HasValue:
|
||||||
{
|
{
|
||||||
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
|
||||||
if (!providerPortalTakeover)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
|
||||||
if (provider != null)
|
if (provider != null)
|
||||||
{
|
{
|
||||||
@ -321,13 +314,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
Event parsedEvent,
|
Event parsedEvent,
|
||||||
Subscription currentSubscription)
|
Subscription currentSubscription)
|
||||||
{
|
{
|
||||||
var providerPortalTakeover = _featureService.IsEnabled(FeatureFlagKeys.PM21821_ProviderPortalTakeover);
|
|
||||||
|
|
||||||
if (!providerPortalTakeover)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||||
if (provider == null)
|
if (provider == null)
|
||||||
{
|
{
|
||||||
@ -343,22 +329,17 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
{
|
{
|
||||||
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
|
var previousSubscription = parsedEvent.Data.PreviousAttributes.ToObject<Subscription>() as Subscription;
|
||||||
|
|
||||||
var updateIsSubscriptionGoingUnpaid = previousSubscription is
|
if (previousSubscription is
|
||||||
{
|
{
|
||||||
Status:
|
Status:
|
||||||
StripeSubscriptionStatus.Trialing or
|
StripeSubscriptionStatus.Trialing or
|
||||||
StripeSubscriptionStatus.Active or
|
StripeSubscriptionStatus.Active or
|
||||||
StripeSubscriptionStatus.PastDue
|
StripeSubscriptionStatus.PastDue
|
||||||
} && currentSubscription is
|
} && currentSubscription is
|
||||||
{
|
{
|
||||||
Status: StripeSubscriptionStatus.Unpaid,
|
Status: StripeSubscriptionStatus.Unpaid,
|
||||||
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
|
LatestInvoice.BillingReason: "subscription_cycle" or "subscription_create"
|
||||||
};
|
})
|
||||||
|
|
||||||
var updateIsManualSuspensionViaMetadata = CheckForManualSuspensionViaMetadata(
|
|
||||||
previousSubscription, currentSubscription);
|
|
||||||
|
|
||||||
if (updateIsSubscriptionGoingUnpaid || updateIsManualSuspensionViaMetadata)
|
|
||||||
{
|
{
|
||||||
if (currentSubscription.TestClock != null)
|
if (currentSubscription.TestClock != null)
|
||||||
{
|
{
|
||||||
@ -369,14 +350,6 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
|
|
||||||
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
|
var subscriptionUpdateOptions = new SubscriptionUpdateOptions { CancelAt = now.AddDays(7) };
|
||||||
|
|
||||||
if (updateIsManualSuspensionViaMetadata)
|
|
||||||
{
|
|
||||||
subscriptionUpdateOptions.Metadata = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["suspended_provider_via_webhook_at"] = DateTime.UtcNow.ToString(CultureInfo.InvariantCulture)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
|
await _stripeFacade.UpdateSubscription(currentSubscription.Id, subscriptionUpdateOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -399,37 +372,4 @@ public class SubscriptionUpdatedHandler : ISubscriptionUpdatedHandler
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool CheckForManualSuspensionViaMetadata(
|
|
||||||
Subscription? previousSubscription,
|
|
||||||
Subscription currentSubscription)
|
|
||||||
{
|
|
||||||
/*
|
|
||||||
* When metadata on a subscription is updated, we'll receive an event that has:
|
|
||||||
* Previous Metadata: { newlyAddedKey: null }
|
|
||||||
* Current Metadata: { newlyAddedKey: newlyAddedValue }
|
|
||||||
*
|
|
||||||
* As such, our check for a manual suspension must ensure that the 'previous_attributes' does contain the
|
|
||||||
* 'metadata' property, but also that the "suspend_provider" key in that metadata is set to null.
|
|
||||||
*
|
|
||||||
* If we don't do this and instead do a null coalescing check on 'previous_attributes?.metadata?.TryGetValue',
|
|
||||||
* we'll end up marking an event where 'previous_attributes.metadata' = null (which could be any subscription update
|
|
||||||
* that does not update the metadata) the same as a manual suspension.
|
|
||||||
*/
|
|
||||||
const string key = "suspend_provider";
|
|
||||||
|
|
||||||
if (previousSubscription is not { Metadata: not null } ||
|
|
||||||
!previousSubscription.Metadata.TryGetValue(key, out var previousValue))
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (previousValue == null)
|
|
||||||
{
|
|
||||||
return !string.IsNullOrEmpty(
|
|
||||||
currentSubscription.Metadata.TryGetValue(key, out var currentValue) ? currentValue : null);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using Bit.Core;
|
using System.Globalization;
|
||||||
|
using Bit.Core;
|
||||||
using Bit.Core.AdminConsole.Entities;
|
using Bit.Core.AdminConsole.Entities;
|
||||||
using Bit.Core.AdminConsole.Entities.Provider;
|
using Bit.Core.AdminConsole.Entities.Provider;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
@ -8,7 +9,9 @@ using Bit.Core.Billing.Extensions;
|
|||||||
using Bit.Core.Billing.Payment.Queries;
|
using Bit.Core.Billing.Payment.Queries;
|
||||||
using Bit.Core.Billing.Pricing;
|
using Bit.Core.Billing.Pricing;
|
||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Models.Mail.UpdatedInvoiceIncoming;
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2019Renewal;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Families2020Renewal;
|
||||||
|
using Bit.Core.Models.Mail.Billing.Renewal.Premium;
|
||||||
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
|
||||||
using Bit.Core.Platform.Mail.Mailer;
|
using Bit.Core.Platform.Mail.Mailer;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
@ -16,6 +19,7 @@ using Bit.Core.Services;
|
|||||||
using Stripe;
|
using Stripe;
|
||||||
using Event = Stripe.Event;
|
using Event = Stripe.Event;
|
||||||
using Plan = Bit.Core.Models.StaticStore.Plan;
|
using Plan = Bit.Core.Models.StaticStore.Plan;
|
||||||
|
using PremiumPlan = Bit.Core.Billing.Pricing.Premium.Plan;
|
||||||
|
|
||||||
namespace Bit.Billing.Services.Implementations;
|
namespace Bit.Billing.Services.Implementations;
|
||||||
|
|
||||||
@ -107,13 +111,22 @@ public class UpcomingInvoiceHandler(
|
|||||||
|
|
||||||
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
|
var milestone3 = featureService.IsEnabled(FeatureFlagKeys.PM26462_Milestone_3);
|
||||||
|
|
||||||
await AlignOrganizationSubscriptionConcernsAsync(
|
var subscriptionAligned = await AlignOrganizationSubscriptionConcernsAsync(
|
||||||
organization,
|
organization,
|
||||||
@event,
|
@event,
|
||||||
subscription,
|
subscription,
|
||||||
plan,
|
plan,
|
||||||
milestone3);
|
milestone3);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
|
||||||
|
* with processing.
|
||||||
|
*/
|
||||||
|
if (subscriptionAligned)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
// Don't send the upcoming invoice email unless the organization's on an annual plan.
|
||||||
if (!plan.IsAnnual)
|
if (!plan.IsAnnual)
|
||||||
{
|
{
|
||||||
@ -135,9 +148,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await (milestone3
|
await SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice);
|
||||||
? SendUpdatedUpcomingInvoiceEmailsAsync([organization.BillingEmail])
|
|
||||||
: SendUpcomingInvoiceEmailsAsync([organization.BillingEmail], invoice));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AlignOrganizationTaxConcernsAsync(
|
private async Task AlignOrganizationTaxConcernsAsync(
|
||||||
@ -188,7 +199,16 @@ public class UpcomingInvoiceHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AlignOrganizationSubscriptionConcernsAsync(
|
/// <summary>
|
||||||
|
/// Aligns the organization's subscription details with the specified plan and milestone requirements.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="organization">The organization whose subscription is being updated.</param>
|
||||||
|
/// <param name="event">The Stripe event associated with this operation.</param>
|
||||||
|
/// <param name="subscription">The organization's subscription.</param>
|
||||||
|
/// <param name="plan">The organization's current plan.</param>
|
||||||
|
/// <param name="milestone3">A flag indicating whether the third milestone is enabled.</param>
|
||||||
|
/// <returns>Whether the operation resulted in an updated subscription.</returns>
|
||||||
|
private async Task<bool> AlignOrganizationSubscriptionConcernsAsync(
|
||||||
Organization organization,
|
Organization organization,
|
||||||
Event @event,
|
Event @event,
|
||||||
Subscription subscription,
|
Subscription subscription,
|
||||||
@ -198,7 +218,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
|
// currently these are the only plans that need aligned and both require the same flag and share most of the logic
|
||||||
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
|
if (!milestone3 || plan.Type is not (PlanType.FamiliesAnnually2019 or PlanType.FamiliesAnnually2025))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var passwordManagerItem =
|
var passwordManagerItem =
|
||||||
@ -208,15 +228,15 @@ public class UpcomingInvoiceHandler(
|
|||||||
{
|
{
|
||||||
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
|
logger.LogWarning("Could not find Organization's ({OrganizationId}) password manager item while processing '{EventType}' event ({EventID})",
|
||||||
organization.Id, @event.Type, @event.Id);
|
organization.Id, @event.Type, @event.Id);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var families = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
|
var familiesPlan = await pricingClient.GetPlanOrThrow(PlanType.FamiliesAnnually);
|
||||||
|
|
||||||
organization.PlanType = families.Type;
|
organization.PlanType = familiesPlan.Type;
|
||||||
organization.Plan = families.Name;
|
organization.Plan = familiesPlan.Name;
|
||||||
organization.UsersGetPremium = families.UsersGetPremium;
|
organization.UsersGetPremium = familiesPlan.UsersGetPremium;
|
||||||
organization.Seats = families.PasswordManager.BaseSeats;
|
organization.Seats = familiesPlan.PasswordManager.BaseSeats;
|
||||||
|
|
||||||
var options = new SubscriptionUpdateOptions
|
var options = new SubscriptionUpdateOptions
|
||||||
{
|
{
|
||||||
@ -225,7 +245,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
new SubscriptionItemOptions
|
new SubscriptionItemOptions
|
||||||
{
|
{
|
||||||
Id = passwordManagerItem.Id,
|
Id = passwordManagerItem.Id,
|
||||||
Price = families.PasswordManager.StripePlanId
|
Price = familiesPlan.PasswordManager.StripePlanId
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
ProrationBehavior = ProrationBehavior.None
|
ProrationBehavior = ProrationBehavior.None
|
||||||
@ -266,6 +286,8 @@ public class UpcomingInvoiceHandler(
|
|||||||
{
|
{
|
||||||
await organizationRepository.ReplaceAsync(organization);
|
await organizationRepository.ReplaceAsync(organization);
|
||||||
await stripeFacade.UpdateSubscription(subscription.Id, options);
|
await stripeFacade.UpdateSubscription(subscription.Id, options);
|
||||||
|
await SendFamiliesRenewalEmailAsync(organization, familiesPlan, plan);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@ -275,6 +297,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
organization.Id,
|
organization.Id,
|
||||||
@event.Type,
|
@event.Type,
|
||||||
@event.Id);
|
@event.Id);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -303,14 +326,21 @@ public class UpcomingInvoiceHandler(
|
|||||||
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
var milestone2Feature = featureService.IsEnabled(FeatureFlagKeys.PM23341_Milestone_2);
|
||||||
if (milestone2Feature)
|
if (milestone2Feature)
|
||||||
{
|
{
|
||||||
await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
|
var subscriptionAligned = await AlignPremiumUsersSubscriptionConcernsAsync(user, @event, subscription);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Subscription alignment sends out a different version of our Upcoming Invoice email, so we don't need to continue
|
||||||
|
* with processing.
|
||||||
|
*/
|
||||||
|
if (subscriptionAligned)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.Premium)
|
if (user.Premium)
|
||||||
{
|
{
|
||||||
await (milestone2Feature
|
await SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice);
|
||||||
? SendUpdatedUpcomingInvoiceEmailsAsync(new List<string> { user.Email })
|
|
||||||
: SendUpcomingInvoiceEmailsAsync(new List<string> { user.Email }, invoice));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -341,7 +371,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AlignPremiumUsersSubscriptionConcernsAsync(
|
private async Task<bool> AlignPremiumUsersSubscriptionConcernsAsync(
|
||||||
User user,
|
User user,
|
||||||
Event @event,
|
Event @event,
|
||||||
Subscription subscription)
|
Subscription subscription)
|
||||||
@ -352,7 +382,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
{
|
{
|
||||||
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
|
logger.LogWarning("Could not find User's ({UserID}) premium subscription item while processing '{EventType}' event ({EventID})",
|
||||||
user.Id, @event.Type, @event.Id);
|
user.Id, @event.Type, @event.Id);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@ -371,6 +401,8 @@ public class UpcomingInvoiceHandler(
|
|||||||
],
|
],
|
||||||
ProrationBehavior = ProrationBehavior.None
|
ProrationBehavior = ProrationBehavior.None
|
||||||
});
|
});
|
||||||
|
await SendPremiumRenewalEmailAsync(user, plan);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@ -379,6 +411,7 @@ public class UpcomingInvoiceHandler(
|
|||||||
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
|
"Failed to update user's ({UserID}) subscription price id while processing event with ID {EventID}",
|
||||||
user.Id,
|
user.Id,
|
||||||
@event.Id);
|
@event.Id);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -513,15 +546,92 @@ public class UpcomingInvoiceHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendUpdatedUpcomingInvoiceEmailsAsync(IEnumerable<string> emails)
|
private async Task SendFamiliesRenewalEmailAsync(
|
||||||
|
Organization organization,
|
||||||
|
Plan familiesPlan,
|
||||||
|
Plan planBeforeAlignment)
|
||||||
{
|
{
|
||||||
var validEmails = emails.Where(e => !string.IsNullOrEmpty(e));
|
await (planBeforeAlignment switch
|
||||||
var updatedUpcomingEmail = new UpdatedInvoiceUpcomingMail
|
|
||||||
{
|
{
|
||||||
ToEmails = validEmails,
|
{ Type: PlanType.FamiliesAnnually2025 } => SendFamilies2020RenewalEmailAsync(organization, familiesPlan),
|
||||||
View = new UpdatedInvoiceUpcomingView()
|
{ Type: PlanType.FamiliesAnnually2019 } => SendFamilies2019RenewalEmailAsync(organization, familiesPlan),
|
||||||
|
_ => throw new InvalidOperationException("Unsupported families plan in SendFamiliesRenewalEmailAsync().")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendFamilies2020RenewalEmailAsync(Organization organization, Plan familiesPlan)
|
||||||
|
{
|
||||||
|
var email = new Families2020RenewalMail
|
||||||
|
{
|
||||||
|
ToEmails = [organization.BillingEmail],
|
||||||
|
View = new Families2020RenewalMailView
|
||||||
|
{
|
||||||
|
MonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
await mailer.SendEmail(updatedUpcomingEmail);
|
|
||||||
|
await mailer.SendEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendFamilies2019RenewalEmailAsync(Organization organization, Plan familiesPlan)
|
||||||
|
{
|
||||||
|
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone3SubscriptionDiscount);
|
||||||
|
if (coupon == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Coupon for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.PercentOff == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"coupon.PercentOff for sending families 2019 email id:{CouponIDs.Milestone3SubscriptionDiscount} is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var discountedAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice * (100 - coupon.PercentOff.Value) / 100;
|
||||||
|
|
||||||
|
var email = new Families2019RenewalMail
|
||||||
|
{
|
||||||
|
ToEmails = [organization.BillingEmail],
|
||||||
|
View = new Families2019RenewalMailView
|
||||||
|
{
|
||||||
|
BaseMonthlyRenewalPrice = (familiesPlan.PasswordManager.BasePrice / 12).ToString("C", new CultureInfo("en-US")),
|
||||||
|
BaseAnnualRenewalPrice = familiesPlan.PasswordManager.BasePrice.ToString("C", new CultureInfo("en-US")),
|
||||||
|
DiscountAmount = $"{coupon.PercentOff}%",
|
||||||
|
DiscountedAnnualRenewalPrice = discountedAnnualRenewalPrice.ToString("C", new CultureInfo("en-US"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailer.SendEmail(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendPremiumRenewalEmailAsync(
|
||||||
|
User user,
|
||||||
|
PremiumPlan premiumPlan)
|
||||||
|
{
|
||||||
|
var coupon = await stripeFacade.GetCoupon(CouponIDs.Milestone2SubscriptionDiscount);
|
||||||
|
if (coupon == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Coupon for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coupon.PercentOff == null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"coupon.PercentOff for sending premium renewal email id:{CouponIDs.Milestone2SubscriptionDiscount} is null");
|
||||||
|
}
|
||||||
|
|
||||||
|
var discountedAnnualRenewalPrice = premiumPlan.Seat.Price * (100 - coupon.PercentOff.Value) / 100;
|
||||||
|
|
||||||
|
var email = new PremiumRenewalMail
|
||||||
|
{
|
||||||
|
ToEmails = [user.Email],
|
||||||
|
View = new PremiumRenewalMailView
|
||||||
|
{
|
||||||
|
BaseMonthlyRenewalPrice = (premiumPlan.Seat.Price / 12).ToString("C", new CultureInfo("en-US")),
|
||||||
|
DiscountAmount = $"{coupon.PercentOff}%",
|
||||||
|
DiscountedMonthlyRenewalPrice = (discountedAnnualRenewalPrice / 12).ToString("C", new CultureInfo("en-US"))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailer.SendEmail(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -134,6 +134,11 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// If set to true, the organization has phishing protection enabled.
|
||||||
|
/// </summary>
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
|
|
||||||
public void SetNewId()
|
public void SetNewId()
|
||||||
{
|
{
|
||||||
if (Id == default(Guid))
|
if (Id == default(Guid))
|
||||||
@ -334,5 +339,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
|||||||
UseOrganizationDomains = license.UseOrganizationDomains;
|
UseOrganizationDomains = license.UseOrganizationDomains;
|
||||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
||||||
|
UsePhishingBlocker = license.UsePhishingBlocker;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Entities;
|
namespace Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
public class OrganizationIntegration : ITableObject<Guid>
|
public class OrganizationIntegration : ITableObject<Guid>
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Utilities;
|
using Bit.Core.Utilities;
|
||||||
|
|
||||||
#nullable enable
|
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.Entities;
|
namespace Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
public class OrganizationIntegrationConfiguration : ITableObject<Guid>
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -53,4 +53,5 @@ public interface IProfileOrganizationDetails
|
|||||||
bool UseAdminSponsoredFamilies { get; set; }
|
bool UseAdminSponsoredFamilies { get; set; }
|
||||||
bool UseOrganizationDomains { get; set; }
|
bool UseOrganizationDomains { get; set; }
|
||||||
bool UseAutomaticUserConfirmation { get; set; }
|
bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,6 +29,7 @@ public class OrganizationAbility
|
|||||||
UseOrganizationDomains = organization.UseOrganizationDomains;
|
UseOrganizationDomains = organization.UseOrganizationDomains;
|
||||||
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
|
||||||
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
|
||||||
|
UsePhishingBlocker = organization.UsePhishingBlocker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
@ -51,4 +52,5 @@ public class OrganizationAbility
|
|||||||
public bool UseOrganizationDomains { get; set; }
|
public bool UseOrganizationDomains { get; set; }
|
||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,4 +65,5 @@ public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
|
|||||||
public bool UseAdminSponsoredFamilies { get; set; }
|
public bool UseAdminSponsoredFamilies { get; set; }
|
||||||
public bool? IsAdminInitiated { get; set; }
|
public bool? IsAdminInitiated { get; set; }
|
||||||
public bool UseAutomaticUserConfirmation { get; set; }
|
public bool UseAutomaticUserConfirmation { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,6 +154,7 @@ public class SelfHostedOrganizationDetails : Organization
|
|||||||
Status = Status,
|
Status = Status,
|
||||||
UseRiskInsights = UseRiskInsights,
|
UseRiskInsights = UseRiskInsights,
|
||||||
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
UseAdminSponsoredFamilies = UseAdminSponsoredFamilies,
|
||||||
|
UsePhishingBlocker = UsePhishingBlocker,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,4 +56,5 @@ public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
|
|||||||
public string? SsoExternalId { get; set; }
|
public string? SsoExternalId { get; set; }
|
||||||
public string? Permissions { get; set; }
|
public string? Permissions { get; set; }
|
||||||
public string? ResetPasswordKey { get; set; }
|
public string? ResetPasswordKey { get; set; }
|
||||||
|
public bool UsePhishingBlocker { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
using Bit.Core.AdminConsole.Enums;
|
using Bit.Core.AdminConsole.Enums;
|
||||||
using Bit.Core.AdminConsole.Models.Data;
|
using Bit.Core.AdminConsole.Models.Data;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationDomains.Interfaces;
|
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.Models;
|
||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||||
using Bit.Core.Context;
|
using Bit.Core.Context;
|
||||||
@ -25,8 +24,6 @@ public class VerifyOrganizationDomainCommand(
|
|||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IGlobalSettings globalSettings,
|
IGlobalSettings globalSettings,
|
||||||
ICurrentContext currentContext,
|
ICurrentContext currentContext,
|
||||||
IFeatureService featureService,
|
|
||||||
ISavePolicyCommand savePolicyCommand,
|
|
||||||
IVNextSavePolicyCommand vNextSavePolicyCommand,
|
IVNextSavePolicyCommand vNextSavePolicyCommand,
|
||||||
IMailService mailService,
|
IMailService mailService,
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
@ -144,15 +141,8 @@ public class VerifyOrganizationDomainCommand(
|
|||||||
PerformedBy = actingUser
|
PerformedBy = actingUser
|
||||||
};
|
};
|
||||||
|
|
||||||
if (featureService.IsEnabled(FeatureFlagKeys.PolicyValidatorsRefactor))
|
var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser);
|
||||||
{
|
await vNextSavePolicyCommand.SaveAsync(savePolicyModel);
|
||||||
var savePolicyModel = new SavePolicyModel(policyUpdate, actingUser);
|
|
||||||
await vNextSavePolicyCommand.SaveAsync(savePolicyModel);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await savePolicyCommand.SaveAsync(policyUpdate);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain)
|
private async Task SendVerifiedDomainUserEmailAsync(OrganizationDomain domain)
|
||||||
|
|||||||
@ -0,0 +1,22 @@
|
|||||||
|
# Automatic User Confirmation
|
||||||
|
|
||||||
|
Owned by: admin-console
|
||||||
|
|
||||||
|
Automatic confirmation requests are server driven events that are sent to the admin's client where via a background service the confirmation will occur. The basic model
|
||||||
|
for the workflow is as follows:
|
||||||
|
|
||||||
|
- The Api server sends an invite email to a user.
|
||||||
|
- The user accepts the invite request, which is sent back to the Api server
|
||||||
|
- The Api server sends a push-notification with the OrganizationId and UserId to a client admin session.
|
||||||
|
- The Client performs the key exchange in the background and POSTs the ConfirmRequest back to the Api server
|
||||||
|
- The Api server runs the OrgUser_Confirm sproc to confirm the user in the DB
|
||||||
|
|
||||||
|
This Feature has the following security measures in place in order to achieve our security goals:
|
||||||
|
|
||||||
|
- The single organization exemption for admins/owners is removed for this policy.
|
||||||
|
- This is enforced by preventing enabling the policy and organization plan feature if there are non-compliant users
|
||||||
|
- Emergency access is removed for all organization users
|
||||||
|
- Automatic confirmation will only apply to the User role (You cannot auto confirm admins/owners to an organization)
|
||||||
|
- The organization has no members with the Provider user type.
|
||||||
|
- This will also prevent the policy and organization plan feature from being enabled
|
||||||
|
- This will prevent sending organization invites to provider users
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
using Bit.Core.Entities;
|
using Bit.Core.Entities;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||||
|
|
||||||
public interface IRevokeOrganizationUserCommand
|
public interface IRevokeOrganizationUserCommand
|
||||||
{
|
{
|
||||||
@ -7,7 +7,7 @@ using Bit.Core.Platform.Push;
|
|||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1;
|
||||||
|
|
||||||
public class RevokeOrganizationUserCommand(
|
public class RevokeOrganizationUserCommand(
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
@ -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.");
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||||
|
|
||||||
|
public interface IOrganizationUpdateCommand
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Updates an organization's information in the Bitwarden database and Stripe (if required).
|
||||||
|
/// Also optionally updates an organization's public-private keypair if it was not created with one.
|
||||||
|
/// On self-host, only the public-private keys will be updated because all other properties are fixed by the license file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The update request containing the details to be updated.</param>
|
||||||
|
Task<Organization> UpdateAsync(OrganizationUpdateRequest request);
|
||||||
|
}
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
|
||||||
|
using Bit.Core.Billing.Organizations.Services;
|
||||||
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Repositories;
|
||||||
|
using Bit.Core.Services;
|
||||||
|
using Bit.Core.Settings;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||||
|
|
||||||
|
public class OrganizationUpdateCommand(
|
||||||
|
IOrganizationService organizationService,
|
||||||
|
IOrganizationRepository organizationRepository,
|
||||||
|
IGlobalSettings globalSettings,
|
||||||
|
IOrganizationBillingService organizationBillingService
|
||||||
|
) : IOrganizationUpdateCommand
|
||||||
|
{
|
||||||
|
public async Task<Organization> UpdateAsync(OrganizationUpdateRequest request)
|
||||||
|
{
|
||||||
|
var organization = await organizationRepository.GetByIdAsync(request.OrganizationId);
|
||||||
|
if (organization == null)
|
||||||
|
{
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalSettings.SelfHosted)
|
||||||
|
{
|
||||||
|
return await UpdateSelfHostedAsync(organization, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await UpdateCloudAsync(organization, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Organization> UpdateCloudAsync(Organization organization, OrganizationUpdateRequest request)
|
||||||
|
{
|
||||||
|
// Store original values for comparison
|
||||||
|
var originalName = organization.Name;
|
||||||
|
var originalBillingEmail = organization.BillingEmail;
|
||||||
|
|
||||||
|
// Apply updates to organization
|
||||||
|
organization.UpdateDetails(request);
|
||||||
|
organization.BackfillPublicPrivateKeys(request);
|
||||||
|
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
|
||||||
|
|
||||||
|
// Update billing information in Stripe if required
|
||||||
|
await UpdateBillingAsync(organization, originalName, originalBillingEmail);
|
||||||
|
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Self-host cannot update the organization details because they are set by the license file.
|
||||||
|
/// However, this command does offer a soft migration pathway for organizations without public and private keys.
|
||||||
|
/// If we remove this migration code in the future, this command and endpoint can become cloud only.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Organization> UpdateSelfHostedAsync(Organization organization, OrganizationUpdateRequest request)
|
||||||
|
{
|
||||||
|
organization.BackfillPublicPrivateKeys(request);
|
||||||
|
await organizationService.ReplaceAndUpdateCacheAsync(organization, EventType.Organization_Updated);
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateBillingAsync(Organization organization, string originalName, string? originalBillingEmail)
|
||||||
|
{
|
||||||
|
// Update Stripe if name or billing email changed
|
||||||
|
var shouldUpdateBilling = originalName != organization.Name ||
|
||||||
|
originalBillingEmail != organization.BillingEmail;
|
||||||
|
|
||||||
|
if (!shouldUpdateBilling || string.IsNullOrWhiteSpace(organization.GatewayCustomerId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await organizationBillingService.UpdateOrganizationNameAndEmail(organization);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
using Bit.Core.AdminConsole.Entities;
|
||||||
|
|
||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||||
|
|
||||||
|
public static class OrganizationUpdateExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the organization name and/or billing email.
|
||||||
|
/// Any null property on the request object will be skipped.
|
||||||
|
/// </summary>
|
||||||
|
public static void UpdateDetails(this Organization organization, OrganizationUpdateRequest request)
|
||||||
|
{
|
||||||
|
// These values may or may not be sent by the client depending on the operation being performed.
|
||||||
|
// Skip any values not provided.
|
||||||
|
if (request.Name is not null)
|
||||||
|
{
|
||||||
|
organization.Name = request.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.BillingEmail is not null)
|
||||||
|
{
|
||||||
|
organization.BillingEmail = request.BillingEmail.ToLowerInvariant().Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the organization public and private keys if provided and not already set.
|
||||||
|
/// This is legacy code for old organizations that were not created with a public/private keypair. It is a soft
|
||||||
|
/// migration that will silently migrate organizations when they change their details.
|
||||||
|
/// </summary>
|
||||||
|
public static void BackfillPublicPrivateKeys(this Organization organization, OrganizationUpdateRequest request)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.PublicKey) && string.IsNullOrWhiteSpace(organization.PublicKey))
|
||||||
|
{
|
||||||
|
organization.PublicKey = request.PublicKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.EncryptedPrivateKey) && string.IsNullOrWhiteSpace(organization.PrivateKey))
|
||||||
|
{
|
||||||
|
organization.PrivateKey = request.EncryptedPrivateKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Update;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request model for updating the name, billing email, and/or public-private keys for an organization (legacy migration code).
|
||||||
|
/// Any combination of these properties can be updated, so they are optional. If none are specified it will not update anything.
|
||||||
|
/// </summary>
|
||||||
|
public record OrganizationUpdateRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The ID of the organization to update.
|
||||||
|
/// </summary>
|
||||||
|
public required Guid OrganizationId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new organization name to apply (optional, this is skipped if not provided).
|
||||||
|
/// </summary>
|
||||||
|
public string? Name { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The new billing email address to apply (optional, this is skipped if not provided).
|
||||||
|
/// </summary>
|
||||||
|
public string? BillingEmail { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The organization's public key to set (optional, only set if not already present on the organization).
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The organization's encrypted private key to set (optional, only set if not already present on the organization).
|
||||||
|
/// </summary>
|
||||||
|
public string? EncryptedPrivateKey { get; init; }
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models;
|
||||||
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||||
@ -16,19 +18,22 @@ public class SavePolicyCommand : ISavePolicyCommand
|
|||||||
private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators;
|
private readonly IReadOnlyDictionary<PolicyType, IPolicyValidator> _policyValidators;
|
||||||
private readonly TimeProvider _timeProvider;
|
private readonly TimeProvider _timeProvider;
|
||||||
private readonly IPostSavePolicySideEffect _postSavePolicySideEffect;
|
private readonly IPostSavePolicySideEffect _postSavePolicySideEffect;
|
||||||
|
private readonly IPushNotificationService _pushNotificationService;
|
||||||
|
|
||||||
public SavePolicyCommand(IApplicationCacheService applicationCacheService,
|
public SavePolicyCommand(IApplicationCacheService applicationCacheService,
|
||||||
IEventService eventService,
|
IEventService eventService,
|
||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IEnumerable<IPolicyValidator> policyValidators,
|
IEnumerable<IPolicyValidator> policyValidators,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IPostSavePolicySideEffect postSavePolicySideEffect)
|
IPostSavePolicySideEffect postSavePolicySideEffect,
|
||||||
|
IPushNotificationService pushNotificationService)
|
||||||
{
|
{
|
||||||
_applicationCacheService = applicationCacheService;
|
_applicationCacheService = applicationCacheService;
|
||||||
_eventService = eventService;
|
_eventService = eventService;
|
||||||
_policyRepository = policyRepository;
|
_policyRepository = policyRepository;
|
||||||
_timeProvider = timeProvider;
|
_timeProvider = timeProvider;
|
||||||
_postSavePolicySideEffect = postSavePolicySideEffect;
|
_postSavePolicySideEffect = postSavePolicySideEffect;
|
||||||
|
_pushNotificationService = pushNotificationService;
|
||||||
|
|
||||||
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
|
var policyValidatorsDict = new Dictionary<PolicyType, IPolicyValidator>();
|
||||||
foreach (var policyValidator in policyValidators)
|
foreach (var policyValidator in policyValidators)
|
||||||
@ -75,6 +80,8 @@ public class SavePolicyCommand : ISavePolicyCommand
|
|||||||
await _policyRepository.UpsertAsync(policy);
|
await _policyRepository.UpsertAsync(policy);
|
||||||
await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated);
|
await _eventService.LogPolicyEventAsync(policy, EventType.Policy_Updated);
|
||||||
|
|
||||||
|
await PushPolicyUpdateToClients(policy.OrganizationId, policy);
|
||||||
|
|
||||||
return policy;
|
return policy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,4 +159,17 @@ public class SavePolicyCommand : ISavePolicyCommand
|
|||||||
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type);
|
var currentPolicy = savedPoliciesDict.GetValueOrDefault(policyUpdate.Type);
|
||||||
return (savedPoliciesDict, currentPolicy);
|
return (savedPoliciesDict, currentPolicy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => this._pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
|
||||||
|
{
|
||||||
|
Type = PushType.PolicyChanged,
|
||||||
|
Target = NotificationTarget.Organization,
|
||||||
|
TargetId = organizationId,
|
||||||
|
ExcludeCurrentContext = false,
|
||||||
|
Payload = new SyncPolicyPushNotification
|
||||||
|
{
|
||||||
|
Policy = policy,
|
||||||
|
OrganizationId = organizationId
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,8 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Int
|
|||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
using Bit.Core.Exceptions;
|
using Bit.Core.Exceptions;
|
||||||
|
using Bit.Core.Models;
|
||||||
|
using Bit.Core.Platform.Push;
|
||||||
using Bit.Core.Services;
|
using Bit.Core.Services;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.Implementations;
|
||||||
@ -15,7 +17,8 @@ public class VNextSavePolicyCommand(
|
|||||||
IPolicyRepository policyRepository,
|
IPolicyRepository policyRepository,
|
||||||
IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
|
IEnumerable<IPolicyUpdateEvent> policyUpdateEventHandlers,
|
||||||
TimeProvider timeProvider,
|
TimeProvider timeProvider,
|
||||||
IPolicyEventHandlerFactory policyEventHandlerFactory)
|
IPolicyEventHandlerFactory policyEventHandlerFactory,
|
||||||
|
IPushNotificationService pushNotificationService)
|
||||||
: IVNextSavePolicyCommand
|
: IVNextSavePolicyCommand
|
||||||
{
|
{
|
||||||
|
|
||||||
@ -74,7 +77,7 @@ public class VNextSavePolicyCommand(
|
|||||||
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
policy.RevisionDate = timeProvider.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
await policyRepository.UpsertAsync(policy);
|
await policyRepository.UpsertAsync(policy);
|
||||||
|
await PushPolicyUpdateToClients(policyUpdateRequest.OrganizationId, policy);
|
||||||
return policy;
|
return policy;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,4 +195,17 @@ public class VNextSavePolicyCommand(
|
|||||||
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
|
var savedPoliciesDict = savedPolicies.ToDictionary(p => p.Type);
|
||||||
return savedPoliciesDict;
|
return savedPoliciesDict;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Task PushPolicyUpdateToClients(Guid organizationId, Policy policy) => pushNotificationService.PushAsync(new PushNotification<SyncPolicyPushNotification>
|
||||||
|
{
|
||||||
|
Type = PushType.PolicyChanged,
|
||||||
|
Target = NotificationTarget.Organization,
|
||||||
|
TargetId = organizationId,
|
||||||
|
ExcludeCurrentContext = false,
|
||||||
|
Payload = new SyncPolicyPushNotification
|
||||||
|
{
|
||||||
|
Policy = policy,
|
||||||
|
OrganizationId = organizationId
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ public static class PolicyServiceCollectionExtensions
|
|||||||
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
|
||||||
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
|
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
|
||||||
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
|
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
|
||||||
|
services.AddScoped<IPolicyValidator, BlockClaimedDomainAccountCreationPolicyValidator>();
|
||||||
|
services.AddScoped<IPolicyValidator, AutomaticUserConfirmationPolicyEventHandler>();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Obsolete("Use AddPolicyUpdateEvents instead.")]
|
[Obsolete("Use AddPolicyUpdateEvents instead.")]
|
||||||
|
|||||||
@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
|
|||||||
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
|
||||||
using Bit.Core.AdminConsole.Repositories;
|
using Bit.Core.AdminConsole.Repositories;
|
||||||
using Bit.Core.Enums;
|
using Bit.Core.Enums;
|
||||||
|
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||||
using Bit.Core.Repositories;
|
using Bit.Core.Repositories;
|
||||||
|
|
||||||
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
|
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>All organization users are compliant with the Single organization policy</li>
|
||||||
/// <li>No provider users exist</li>
|
/// <li>No provider users exist</li>
|
||||||
/// </ul>
|
/// </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>
|
/// </summary>
|
||||||
public class AutomaticUserConfirmationPolicyEventHandler(
|
public class AutomaticUserConfirmationPolicyEventHandler(
|
||||||
IOrganizationUserRepository organizationUserRepository,
|
IOrganizationUserRepository organizationUserRepository,
|
||||||
IProviderUserRepository providerUserRepository,
|
IProviderUserRepository providerUserRepository)
|
||||||
IPolicyRepository policyRepository,
|
: IPolicyValidator, IPolicyValidationEvent, IEnforceDependentPoliciesEvent
|
||||||
IOrganizationRepository organizationRepository,
|
|
||||||
TimeProvider timeProvider)
|
|
||||||
: IPolicyValidator, IPolicyValidationEvent, IOnPolicyPreUpdateEvent, IEnforceDependentPoliciesEvent
|
|
||||||
{
|
{
|
||||||
public PolicyType Type => PolicyType.AutomaticUserConfirmation;
|
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 =
|
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.";
|
"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) =>
|
public async Task<string> ValidateAsync(SavePolicyModel savePolicyModel, Policy? currentPolicy) =>
|
||||||
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
await ValidateAsync(savePolicyModel.PolicyUpdate, currentPolicy);
|
||||||
|
|
||||||
public async Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy)
|
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) =>
|
||||||
{
|
Task.CompletedTask;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> ValidateEnablingPolicyAsync(Guid organizationId)
|
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))
|
if (!string.IsNullOrWhiteSpace(singleOrgValidationError))
|
||||||
{
|
{
|
||||||
return singleOrgValidationError;
|
return singleOrgValidationError;
|
||||||
}
|
}
|
||||||
|
|
||||||
var providerValidationError = await ValidateNoProviderUsersAsync(organizationId);
|
var providerValidationError = await ValidateNoProviderUsersAsync(organizationUsers);
|
||||||
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
if (!string.IsNullOrWhiteSpace(providerValidationError))
|
||||||
{
|
{
|
||||||
return providerValidationError;
|
return providerValidationError;
|
||||||
@ -90,42 +71,24 @@ public class AutomaticUserConfirmationPolicyEventHandler(
|
|||||||
return string.Empty;
|
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(
|
var hasNonCompliantUser = (await organizationUserRepository.GetManyByManyUsersAsync(
|
||||||
organizationUsers.Select(ou => ou.UserId!.Value)))
|
organizationUsers.Select(ou => ou.UserId!.Value)))
|
||||||
.Any(uo => uo.OrganizationId != organizationId &&
|
.Any(uo => uo.OrganizationId != organizationId
|
||||||
uo.Status != OrganizationUserStatusType.Invited);
|
&& uo.Status != OrganizationUserStatusType.Invited);
|
||||||
|
|
||||||
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
|
return hasNonCompliantUser ? _usersNotCompliantWithSingleOrgErrorMessage : string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> ValidateNoProviderUsersAsync(Guid organizationId)
|
private async Task<string> ValidateNoProviderUsersAsync(ICollection<OrganizationUserUserDetails> organizationUsers)
|
||||||
{
|
{
|
||||||
var providerUsers = await providerUserRepository.GetManyByOrganizationAsync(organizationId);
|
var userIds = organizationUsers.Where(x => x.UserId is not null)
|
||||||
|
.Select(x => x.UserId!.Value);
|
||||||
|
|
||||||
return providerUsers.Count > 0 ? _providerUsersExistErrorMessage : string.Empty;
|
return (await providerUserRepository.GetManyByManyUsersAsync(userIds)).Count != 0
|
||||||
|
? _providerUsersExistErrorMessage
|
||||||
|
: string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user