1
0
mirror of https://github.com/bitwarden/server synced 2025-12-20 02:03:46 +00:00

Merge remote-tracking branch 'origin/main' into xunit-v3-full-upgrade

This commit is contained in:
Justin Baur
2025-12-12 16:00:18 -05:00
523 changed files with 34986 additions and 7245 deletions

1
.github/CODEOWNERS vendored
View File

@@ -36,6 +36,7 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# UIF # UIF
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF.
# Auth team # Auth team
**/Auth @bitwarden/team-auth-dev **/Auth @bitwarden/team-auth-dev

View File

@@ -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:
@@ -70,15 +70,6 @@ body:
mariadb:10 mariadb:10
# Postgres Example # Postgres Example
postgres:14 postgres:14
- type: textarea
id: epic-label
attributes:
label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Lite
value: |
https://github.com/bitwarden/server/issues/2480
validations:
required: true
- type: checkboxes - type: checkboxes
id: issue-tracking-info id: issue-tracking-info
attributes: attributes:

View File

@@ -42,8 +42,9 @@
dependencyDashboardApproval: false, dependencyDashboardApproval: false,
}, },
{ {
matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"], 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",
@@ -90,11 +90,7 @@
"Microsoft.AspNetCore.Mvc.Testing", "Microsoft.AspNetCore.Mvc.Testing",
"Newtonsoft.Json", "Newtonsoft.Json",
"NSubstitute", "NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File", "Serilog.Extensions.Logging.File",
"Serilog.Sinks.SyslogMessages",
"Stripe.net", "Stripe.net",
"Swashbuckle.AspNetCore", "Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen", "Swashbuckle.AspNetCore.SwaggerGen",
@@ -140,6 +136,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",

View File

@@ -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@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8 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',

View File

@@ -22,9 +22,7 @@ on:
required: false required: false
type: string type: string
permissions: permissions: {}
pull-requests: write
contents: write
jobs: jobs:
setup: setup:
@@ -32,6 +30,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
outputs: outputs:
branch: ${{ steps.set-branch.outputs.branch }} branch: ${{ steps.set-branch.outputs.branch }}
permissions: {}
steps: steps:
- name: Set branch - name: Set branch
id: set-branch id: set-branch
@@ -89,6 +88,7 @@ jobs:
with: with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
- name: Check out branch - name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -212,6 +212,7 @@ jobs:
with: with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }} app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }} private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
- name: Check out target ref - name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@@ -240,10 +241,5 @@ jobs:
move_edd_db_scripts: move_edd_db_scripts:
name: Move EDD database scripts name: Move EDD database scripts
needs: cut_branch needs: cut_branch
permissions: permissions: {}
actions: read
contents: write
id-token: write
pull-requests: write
uses: ./.github/workflows/_move_edd_db_scripts.yml uses: ./.github/workflows/_move_edd_db_scripts.yml
secrets: inherit

View File

@@ -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 -- --coverage --coverage-output-format cobertura --coverage-output "coverage.cobertura.xml" --report-xunit-trx --report-xunit-trx-filename "infrastructure-test-results.trx" run: dotnet test -- --coverage --coverage-output-format cobertura --coverage-output "coverage.cobertura.xml" --report-xunit-trx --report-xunit-trx-filename "infrastructure-test-results.trx"
@@ -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

View File

@@ -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>
@@ -26,7 +26,7 @@
<PropertyGroup> <PropertyGroup>
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion> <MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<XUnitv3Version>3.0.1</XUnitv3Version> <XUnitv3Version>3.0.1</XUnitv3Version>

View File

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

View File

@@ -10,6 +10,7 @@ public static class SecretsManagerEfServiceCollectionExtensions
{ {
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>(); services.AddSingleton<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>();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
using System.ComponentModel.DataAnnotations; 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;

View File

@@ -11,21 +11,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder => .ConfigureWebHostDefaults(webBuilder =>
{ {
webBuilder.UseStartup<Startup>(); webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default;
}));
}) })
.AddSerilogFileLogging()
.Build() .Build()
.Run(); .Run();
} }

View File

@@ -94,11 +94,8 @@ public class Startup
public void Configure( public void Configure(
IApplicationBuilder app, IApplicationBuilder app,
IWebHostEnvironment env, IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings) GlobalSettings globalSettings)
{ {
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers // Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseMiddleware<SecurityHeadersMiddleware>();

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@
}, },
"storage": { "storage": {
"connectionString": "UseDevelopmentStorage=true" "connectionString": "UseDevelopmentStorage=true"
} },
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
} }
} }

View File

@@ -30,9 +30,6 @@
"connectionString": "SECRET", "connectionString": "SECRET",
"applicationCacheTopicName": "SECRET" "applicationCacheTopicName": "SECRET"
}, },
"sentry": {
"dsn": "SECRET"
},
"notificationHub": { "notificationHub": {
"connectionString": "SECRET", "connectionString": "SECRET",
"hubName": "SECRET" "hubName": "SECRET"

View File

@@ -201,12 +201,15 @@ public class AccountController : Controller
returnUrl, returnUrl,
state = context.Parameters["state"], state = context.Parameters["state"],
userIdentifier = context.Parameters["session_state"], userIdentifier = context.Parameters["session_state"],
ssoToken
}); });
} }
[HttpGet] [HttpGet]
public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier) public IActionResult ExternalChallenge(string scheme, string returnUrl, string state, string userIdentifier, string ssoToken)
{ {
ValidateSchemeAgainstSsoToken(scheme, ssoToken);
if (string.IsNullOrEmpty(returnUrl)) if (string.IsNullOrEmpty(returnUrl))
{ {
returnUrl = "~/"; returnUrl = "~/";
@@ -235,6 +238,31 @@ public class AccountController : Controller
return Challenge(props, scheme); return Challenge(props, scheme);
} }
/// <summary>
/// Validates the scheme (organization ID) against the organization ID found in the ssoToken.
/// </summary>
/// <param name="scheme">The authentication scheme (organization ID) to validate.</param>
/// <param name="ssoToken">The SSO token to validate against.</param>
/// <exception cref="Exception">Thrown if the scheme (organization ID) does not match the organization ID found in the ssoToken.</exception>
private void ValidateSchemeAgainstSsoToken(string scheme, string ssoToken)
{
SsoTokenable tokenable;
try
{
tokenable = _dataProtector.Unprotect(ssoToken);
}
catch
{
throw new Exception(_i18nService.T("InvalidSsoToken"));
}
if (!Guid.TryParse(scheme, out var schemeOrgId) || tokenable.OrganizationId != schemeOrgId)
{
throw new Exception(_i18nService.T("SsoOrganizationIdMismatch"));
}
}
[HttpGet] [HttpGet]
public async Task<IActionResult> ExternalCallback() public async Task<IActionResult> ExternalCallback()
{ {

View File

@@ -1,5 +1,4 @@
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Serilog;
namespace Bit.Sso; namespace Bit.Sso;
@@ -13,19 +12,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder => .ConfigureWebHostDefaults(webBuilder =>
{ {
webBuilder.UseStartup<Startup>(); webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default;
}));
}) })
.AddSerilogFileLogging()
.Build() .Build()
.Run(); .Run();
} }

View File

@@ -100,8 +100,6 @@ public class Startup
IdentityModelEventSource.ShowPII = true; IdentityModelEventSource.ShowPII = true;
} }
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers // Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseMiddleware<SecurityHeadersMiddleware>();

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ using Bit.Core.Enums;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
@@ -207,7 +207,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly; organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@@ -296,7 +296,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly; organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
@@ -416,7 +416,7 @@ public class RemoveOrganizationFromProviderCommandTests
organization.PlanType = PlanType.TeamsMonthly; organization.PlanType = PlanType.TeamsMonthly;
organization.Enabled = false; // Start with a disabled organization organization.Enabled = false; // Start with a disabled organization
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);

View File

@@ -20,6 +20,7 @@ using Bit.Core.Models.Business;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures; using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Tokens; using Bit.Core.Tokens;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
@@ -811,12 +812,12 @@ public class ProviderServiceTests
organization.Plan = "Enterprise (Monthly)"; organization.Plan = "Enterprise (Monthly)";
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType)); .Returns(MockPlans.Get(organization.PlanType));
var expectedPlanType = PlanType.EnterpriseMonthly2020; var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(StaticStore.GetPlan(expectedPlanType)); .Returns(MockPlans.Get(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly"; var expectedPlanId = "2020-enterprise-org-seat-monthly";

View File

@@ -18,6 +18,7 @@ using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Test.Billing.Mocks;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
@@ -72,7 +73,7 @@ public class BusinessUnitConverterTests
{ {
organization.PlanType = PlanType.EnterpriseAnnually2020; organization.PlanType = PlanType.EnterpriseAnnually2020;
var enterpriseAnnually2020 = StaticStore.GetPlan(PlanType.EnterpriseAnnually2020); var enterpriseAnnually2020 = MockPlans.Get(PlanType.EnterpriseAnnually2020);
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -134,7 +135,7 @@ public class BusinessUnitConverterTests
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020) _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually2020)
.Returns(enterpriseAnnually2020); .Returns(enterpriseAnnually2020);
var enterpriseAnnually = StaticStore.GetPlan(PlanType.EnterpriseAnnually); var enterpriseAnnually = MockPlans.Get(PlanType.EnterpriseAnnually);
_pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually) _pricingClient.GetPlanOrThrow(PlanType.EnterpriseAnnually)
.Returns(enterpriseAnnually); .Returns(enterpriseAnnually);
@@ -242,7 +243,7 @@ public class BusinessUnitConverterTests
argument.Status == ProviderStatusType.Pending && argument.Status == ProviderStatusType.Pending &&
argument.Type == ProviderType.BusinessUnit)).Returns(provider); argument.Type == ProviderType.BusinessUnit)).Returns(provider);
var plan = StaticStore.GetPlan(organization.PlanType); var plan = MockPlans.Get(organization.PlanType);
_pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan); _pricingClient.GetPlanOrThrow(organization.PlanType).Returns(plan);

View File

@@ -22,7 +22,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using Braintree; using Braintree;
@@ -140,7 +140,7 @@ public class ProviderBillingServiceTests
.Returns(existingPlan); .Returns(existingPlan);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(existingPlan.PlanType)
.Returns(StaticStore.GetPlan(existingPlan.PlanType)); .Returns(MockPlans.Get(existingPlan.PlanType));
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider) sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider)
.Returns(new Subscription .Returns(new Subscription
@@ -155,7 +155,7 @@ public class ProviderBillingServiceTests
Id = "si_ent_annual", Id = "si_ent_annual",
Price = new Price Price = new Price
{ {
Id = StaticStore.GetPlan(PlanType.EnterpriseAnnually).PasswordManager Id = MockPlans.Get(PlanType.EnterpriseAnnually).PasswordManager
.StripeProviderPortalSeatPlanId .StripeProviderPortalSeatPlanId
}, },
Quantity = 10 Quantity = 10
@@ -168,7 +168,7 @@ public class ProviderBillingServiceTests
new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly); new ChangeProviderPlanCommand(provider, providerPlanId, PlanType.EnterpriseMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(command.NewPlan)
.Returns(StaticStore.GetPlan(command.NewPlan)); .Returns(MockPlans.Get(command.NewPlan));
// Act // Act
await sutProvider.Sut.ChangePlan(command); await sutProvider.Sut.ChangePlan(command);
@@ -185,7 +185,7 @@ public class ProviderBillingServiceTests
Arg.Is<SubscriptionUpdateOptions>(p => Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1)); p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = StaticStore.GetPlan(command.NewPlan); var newPlanCfg = MockPlans.Get(command.NewPlan);
await stripeAdapter.Received(1) await stripeAdapter.Received(1)
.SubscriptionUpdateAsync( .SubscriptionUpdateAsync(
Arg.Is(provider.GatewaySubscriptionId), Arg.Is(provider.GatewaySubscriptionId),
@@ -491,7 +491,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans); sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id).Returns(providerPlans);
@@ -514,7 +514,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 50 seats currently assigned with a seat minimum of 100 // 50 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -573,7 +573,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
@@ -598,7 +598,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 95 seats currently assigned with a seat minimum of 100 // 95 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -661,7 +661,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
@@ -686,7 +686,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100 // 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -749,7 +749,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
var providerPlan = providerPlans.First(); var providerPlan = providerPlans.First();
@@ -774,7 +774,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription); sutProvider.GetDependency<ISubscriberService>().GetSubscriptionOrThrow(provider).Returns(subscription);
// 110 seats currently assigned with a seat minimum of 100 // 110 seats currently assigned with a seat minimum of 100
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly); var teamsMonthlyPlan = MockPlans.Get(PlanType.TeamsMonthly);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
@@ -827,13 +827,13 @@ public class ProviderBillingServiceTests
} }
]); ]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
new ProviderOrganizationOrganizationDetails new ProviderOrganizationOrganizationDetails
{ {
Plan = StaticStore.GetPlan(planType).Name, Plan = MockPlans.Get(planType).Name,
Status = OrganizationStatusType.Managed, Status = OrganizationStatusType.Managed,
Seats = 5 Seats = 5
} }
@@ -865,13 +865,13 @@ public class ProviderBillingServiceTests
} }
]); ]);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(StaticStore.GetPlan(planType)); sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(planType).Returns(MockPlans.Get(planType));
sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns( sutProvider.GetDependency<IProviderOrganizationRepository>().GetManyDetailsByProviderAsync(provider.Id).Returns(
[ [
new ProviderOrganizationOrganizationDetails new ProviderOrganizationOrganizationDetails
{ {
Plan = StaticStore.GetPlan(planType).Name, Plan = MockPlans.Get(planType).Name,
Status = OrganizationStatusType.Managed, Status = OrganizationStatusType.Managed,
Seats = 15 Seats = 15
} }
@@ -1238,7 +1238,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans); .Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.EnterpriseMonthly)
.Returns(StaticStore.GetPlan(PlanType.EnterpriseMonthly)); .Returns(MockPlans.Get(PlanType.EnterpriseMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@@ -1266,7 +1266,7 @@ public class ProviderBillingServiceTests
.Returns(providerPlans); .Returns(providerPlans);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly)
.Returns(StaticStore.GetPlan(PlanType.TeamsMonthly)); .Returns(MockPlans.Get(PlanType.TeamsMonthly));
await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider)); await ThrowsBillingExceptionAsync(() => sutProvider.Sut.SetupSubscription(provider));
@@ -1317,7 +1317,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1373,7 +1373,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1449,7 +1449,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1525,7 +1525,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1626,7 +1626,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1704,7 +1704,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id) sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
@@ -1772,8 +1772,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -1806,7 +1806,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -1852,8 +1852,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -1886,7 +1886,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -1932,8 +1932,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -1966,7 +1966,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2006,8 +2006,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -2040,7 +2040,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);
@@ -2086,8 +2086,8 @@ public class ProviderBillingServiceTests
const string enterpriseLineItemId = "enterprise_line_item_id"; const string enterpriseLineItemId = "enterprise_line_item_id";
const string teamsLineItemId = "teams_line_item_id"; const string teamsLineItemId = "teams_line_item_id";
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var enterprisePriceId = MockPlans.Get(PlanType.EnterpriseMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId; var teamsPriceId = MockPlans.Get(PlanType.TeamsMonthly).PasswordManager.StripeProviderPortalSeatPlanId;
var subscription = new Subscription var subscription = new Subscription
{ {
@@ -2120,7 +2120,7 @@ public class ProviderBillingServiceTests
foreach (var plan in providerPlans) foreach (var plan in providerPlans)
{ {
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(plan.PlanType)
.Returns(StaticStore.GetPlan(plan.PlanType)); .Returns(MockPlans.Get(plan.PlanType));
} }
providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans); providerPlanRepository.GetByProviderId(provider.Id).Returns(providerPlans);

View File

@@ -6,7 +6,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Repositories; using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Settings; using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute; using NSubstitute;
@@ -69,7 +69,7 @@ public class MaxProjectsQueryTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization); sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType)); .Returns(MockPlans.Get(organization.PlanType));
var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1); var (limit, overLimit) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, 1);
@@ -114,7 +114,7 @@ public class MaxProjectsQueryTests
.Returns(projects); .Returns(projects);
sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType) sutProvider.GetDependency<IPricingClient>().GetPlan(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType)); .Returns(MockPlans.Get(organization.PlanType));
var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd); var (max, overMax) = await sutProvider.Sut.GetByOrgIdAsync(organization.Id, projectsToAdd);

View File

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

View File

@@ -3,6 +3,7 @@ using System.Security.Claims;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities; using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data; using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories; using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration; using Bit.Core.Auth.UserFeatures.Registration;
@@ -10,6 +11,7 @@ using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Tokens;
using Bit.Sso.Controllers; using Bit.Sso.Controllers;
using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes; using Bit.Test.Common.AutoFixture.Attributes;
@@ -1136,4 +1138,129 @@ public class AccountControllerTest
Assert.NotNull(result.user); Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email); Assert.Equal(email, result.user.Email);
} }
[Theory, BitAutoData]
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var orgId = organization.Id;
var scheme = orgId.ToString();
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// Mock the data protector to return a tokenable with matching org ID
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
var tokenable = new SsoTokenable(organization, 3600);
dataProtector.Unprotect(ssoToken).Returns(tokenable);
// Mock URL helper for IsLocalUrl check
var urlHelper = Substitute.For<IUrlHelper>();
urlHelper.IsLocalUrl(returnUrl).Returns(true);
sutProvider.Sut.Url = urlHelper;
// Mock interaction service for IsValidReturnUrl check
var interactionService = sutProvider.GetDependency<IIdentityServerInteractionService>();
interactionService.IsValidReturnUrl(returnUrl).Returns(true);
// Act
var result = sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken);
// Assert
var challengeResult = Assert.IsType<ChallengeResult>(result);
Assert.Contains(scheme, challengeResult.AuthenticationSchemes);
Assert.NotNull(challengeResult.Properties);
Assert.Equal(scheme, challengeResult.Properties.Items["scheme"]);
Assert.Equal(returnUrl, challengeResult.Properties.Items["return_url"]);
Assert.Equal(state, challengeResult.Properties.Items["state"]);
Assert.Equal(userIdentifier, challengeResult.Properties.Items["user_identifier"]);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithMismatchedOrgId_ThrowsSsoOrganizationIdMismatch(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var correctOrgId = organization.Id;
var wrongOrgId = Guid.NewGuid();
var scheme = wrongOrgId.ToString(); // Different from tokenable's org ID
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// Mock the data protector to return a tokenable with different org ID
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
var tokenable = new SsoTokenable(organization, 3600); // Contains correctOrgId
dataProtector.Unprotect(ssoToken).Returns(tokenable);
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("SsoOrganizationIdMismatch", ex.Message);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithInvalidSchemeFormat_ThrowsSsoOrganizationIdMismatch(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var scheme = "not-a-valid-guid";
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// Mock the data protector to return a valid tokenable
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
var tokenable = new SsoTokenable(organization, 3600);
dataProtector.Unprotect(ssoToken).Returns(tokenable);
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("SsoOrganizationIdMismatch", ex.Message);
}
[Theory, BitAutoData]
public void ExternalChallenge_WithInvalidSsoToken_ThrowsInvalidSsoToken(
SutProvider<AccountController> sutProvider)
{
// Arrange
var orgId = Guid.NewGuid();
var scheme = orgId.ToString();
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "invalid-corrupted-token";
// Mock the data protector to throw when trying to unprotect
var dataProtector = sutProvider.GetDependency<IDataProtectorTokenFactory<SsoTokenable>>();
dataProtector.Unprotect(ssoToken).Returns(_ => throw new Exception("Token validation failed"));
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("InvalidSsoToken", ex.Message);
}
} }

View File

@@ -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()
{ {

View File

@@ -0,0 +1,36 @@
{
"globalSettings": {
"baseServiceUri": {
"vault": "https://localhost:8080",
"api": "http://localhost:4000",
"identity": "http://localhost:33656",
"admin": "http://localhost:62911",
"notifications": "http://localhost:61840",
"sso": "http://localhost:51822",
"internalNotifications": "http://localhost:61840",
"internalAdmin": "http://localhost:62911",
"internalIdentity": "http://localhost:33656",
"internalApi": "http://localhost:4000",
"internalVault": "https://localhost:8080",
"internalSso": "http://localhost:51822",
"internalScim": "http://localhost:44559"
},
"mail": {
"smtp": {
"host": "localhost",
"port": 10250
}
},
"attachment": {
"connectionString": "UseDevelopmentStorage=true",
"baseUrl": "http://localhost:4000/attachments/"
},
"events": {
"connectionString": "UseDevelopmentStorage=true"
},
"storage": {
"connectionString": "UseDevelopmentStorage=true"
},
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -473,6 +473,7 @@ public class OrganizationsController : Controller
organization.UseOrganizationDomains = model.UseOrganizationDomains; organization.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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,19 +16,8 @@ public class Program
o.Limits.MaxRequestLineSize = 20_000; o.Limits.MaxRequestLineSize = 20_000;
}); });
webBuilder.UseStartup<Startup>(); webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.TryGetValue("RequestPath", out var requestPath) &&
!string.IsNullOrWhiteSpace(requestPath?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.AdminSettings.Default;
}));
}) })
.AddSerilogFileLogging()
.Build() .Build()
.Run(); .Run();
} }

View File

@@ -132,11 +132,8 @@ public class Startup
public void Configure( public void Configure(
IApplicationBuilder app, IApplicationBuilder app,
IWebHostEnvironment env, IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings) GlobalSettings globalSettings)
{ {
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers // Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -27,6 +27,7 @@
}, },
"storage": { "storage": {
"connectionString": "UseDevelopmentStorage=true" "connectionString": "UseDevelopmentStorage=true"
} },
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
} }
} }

View File

@@ -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.OrganizationIntegrationConfigurations.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,8 +12,10 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")] [Authorize("Application")]
public class OrganizationIntegrationConfigurationController( public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext, ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository, ICreateOrganizationIntegrationConfigurationCommand createCommand,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller IUpdateOrganizationIntegrationConfigurationCommand updateCommand,
IDeleteOrganizationIntegrationConfigurationCommand deleteCommand,
IGetOrganizationIntegrationConfigurationsQuery getQuery) : Controller
{ {
[HttpGet("")] [HttpGet("")]
public async Task<List<OrganizationIntegrationConfigurationResponseModel>> GetAsync( public async Task<List<OrganizationIntegrationConfigurationResponseModel>> GetAsync(
@@ -24,13 +26,8 @@ public class OrganizationIntegrationConfigurationController(
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId); var configurations = await getQuery.GetManyByIntegrationAsync(organizationId, integrationId);
return configurations return configurations
.Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration)) .Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration))
.ToList(); .ToList();
@@ -46,19 +43,11 @@ public class OrganizationIntegrationConfigurationController(
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId); var configuration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration); var created = await createCommand.CreateAsync(organizationId, integrationId, configuration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
return new OrganizationIntegrationConfigurationResponseModel(created);
} }
[HttpPut("{configurationId:guid}")] [HttpPut("{configurationId:guid}")]
@@ -72,26 +61,11 @@ public class OrganizationIntegrationConfigurationController(
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); var configuration = model.ToOrganizationIntegrationConfiguration(integrationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId) var updated = await updateCommand.UpdateAsync(organizationId, integrationId, configurationId, configuration);
{
throw new NotFoundException();
}
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration); return new OrganizationIntegrationConfigurationResponseModel(updated);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
} }
[HttpDelete("{configurationId:guid}")] [HttpDelete("{configurationId:guid}")]
@@ -101,19 +75,8 @@ public class OrganizationIntegrationConfigurationController(
{ {
throw new NotFoundException(); throw new NotFoundException();
} }
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId); await deleteCommand.DeleteAsync(organizationId, integrationId, configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
} }
[HttpPost("{configurationId:guid}/delete")] [HttpPost("{configurationId:guid}/delete")]

View File

@@ -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")]

View File

@@ -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")]

View File

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

View File

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

View File

@@ -1,6 +1,4 @@
using System.Text.Json; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Models.Data.EventIntegrations;
using Bit.Core.Enums; using Bit.Core.Enums;
@@ -16,38 +14,6 @@ public class OrganizationIntegrationConfigurationRequestModel
public string? Template { get; set; } public string? Template { get; set; }
public bool IsValidForType(IntegrationType integrationType)
{
switch (integrationType)
{
case IntegrationType.CloudBillingSync or IntegrationType.Scim:
return false;
case IntegrationType.Slack:
return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<SlackIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Webhook:
return !string.IsNullOrWhiteSpace(Template) &&
IsConfigurationValid<WebhookIntegrationConfiguration>() &&
IsFiltersValid();
case IntegrationType.Hec:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Datadog:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
case IntegrationType.Teams:
return !string.IsNullOrWhiteSpace(Template) &&
Configuration is null &&
IsFiltersValid();
default:
return false;
}
}
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId) public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(Guid organizationIntegrationId)
{ {
return new OrganizationIntegrationConfiguration() return new OrganizationIntegrationConfiguration()
@@ -59,50 +25,4 @@ public class OrganizationIntegrationConfigurationRequestModel
Template = Template Template = Template
}; };
} }
public OrganizationIntegrationConfiguration ToOrganizationIntegrationConfiguration(OrganizationIntegrationConfiguration currentConfiguration)
{
currentConfiguration.Configuration = Configuration;
currentConfiguration.EventType = EventType;
currentConfiguration.Filters = Filters;
currentConfiguration.Template = Template;
return currentConfiguration;
}
private bool IsConfigurationValid<T>()
{
if (string.IsNullOrWhiteSpace(Configuration))
{
return false;
}
try
{
var config = JsonSerializer.Deserialize<T>(Configuration);
return config is not null;
}
catch
{
return false;
}
}
private bool IsFiltersValid()
{
if (Filters is null)
{
return true;
}
try
{
var filters = JsonSerializer.Deserialize<IntegrationFilterGroup>(Filters);
return filters is not null;
}
catch
{
return false;
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ public class PolicyResponseModel : ResponseModel
{ {
Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data); Data = JsonSerializer.Deserialize<Dictionary<string, object>>(policy.Data);
} }
RevisionDate = policy.RevisionDate;
} }
public Guid Id { get; set; } public Guid Id { get; set; }
@@ -37,4 +38,5 @@ public class PolicyResponseModel : ResponseModel
public PolicyType Type { get; set; } public PolicyType Type { get; set; }
public Dictionary<string, object> Data { get; set; } public Dictionary<string, object> Data { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public DateTime RevisionDate { get; set; }
} }

View File

@@ -1,4 +1,5 @@
using Bit.Core.Enums; using Bit.Core.Billing.Models;
using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities; using Bit.Core.Utilities;
@@ -27,7 +28,7 @@ public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseM
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete; FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil; FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) && FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) SponsoredPlans.Get(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organizationDetails); .UsersCanSponsor(organizationDetails);
AccessSecretsManager = organizationDetails.AccessSecretsManager; AccessSecretsManager = organizationDetails.AccessSecretsManager;
} }

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ using Bit.Api.Models.Response;
using Bit.Core.Auth.Enums; using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Identity; using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders; using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Auth.LoginFeatures.PasswordlessLogin.Interfaces;
using Bit.Core.Auth.Models.Business.Tokenables; using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Services; using Bit.Core.Auth.Services;
using Bit.Core.Context; using Bit.Core.Context;
@@ -35,7 +34,7 @@ public class TwoFactorController : Controller
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly UserManager<User> _userManager; private readonly UserManager<User> _userManager;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IVerifyAuthRequestCommand _verifyAuthRequestCommand; private readonly IAuthRequestRepository _authRequestRepository;
private readonly IDuoUniversalTokenService _duoUniversalTokenService; private readonly IDuoUniversalTokenService _duoUniversalTokenService;
private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector; private readonly IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> _twoFactorAuthenticatorDataProtector;
private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector; private readonly IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> _ssoEmailTwoFactorSessionDataProtector;
@@ -47,7 +46,7 @@ public class TwoFactorController : Controller
IOrganizationService organizationService, IOrganizationService organizationService,
UserManager<User> userManager, UserManager<User> userManager,
ICurrentContext currentContext, ICurrentContext currentContext,
IVerifyAuthRequestCommand verifyAuthRequestCommand, IAuthRequestRepository authRequestRepository,
IDuoUniversalTokenService duoUniversalConfigService, IDuoUniversalTokenService duoUniversalConfigService,
IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector, IDataProtectorTokenFactory<TwoFactorAuthenticatorUserVerificationTokenable> twoFactorAuthenticatorDataProtector,
IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector, IDataProtectorTokenFactory<SsoEmail2faSessionTokenable> ssoEmailTwoFactorSessionDataProtector,
@@ -58,7 +57,7 @@ public class TwoFactorController : Controller
_organizationService = organizationService; _organizationService = organizationService;
_userManager = userManager; _userManager = userManager;
_currentContext = currentContext; _currentContext = currentContext;
_verifyAuthRequestCommand = verifyAuthRequestCommand; _authRequestRepository = authRequestRepository;
_duoUniversalTokenService = duoUniversalConfigService; _duoUniversalTokenService = duoUniversalConfigService;
_twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector; _twoFactorAuthenticatorDataProtector = twoFactorAuthenticatorDataProtector;
_ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector; _ssoEmailTwoFactorSessionDataProtector = ssoEmailTwoFactorSessionDataProtector;
@@ -350,14 +349,15 @@ public class TwoFactorController : Controller
if (user != null) if (user != null)
{ {
// Check if 2FA email is from Passwordless. // Check if 2FA email is from a device approval ("Log in with device") scenario.
if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode)) if (!string.IsNullOrEmpty(requestModel.AuthRequestAccessCode))
{ {
if (await _verifyAuthRequestCommand var authRequest = await _authRequestRepository.GetByIdAsync(new Guid(requestModel.AuthRequestId));
.VerifyAuthRequestAsync(new Guid(requestModel.AuthRequestId), if (authRequest != null &&
requestModel.AuthRequestAccessCode)) authRequest.IsValidForAuthentication(user.Id, requestModel.AuthRequestAccessCode))
{ {
await _twoFactorEmailService.SendTwoFactorEmailAsync(user); await _twoFactorEmailService.SendTwoFactorEmailAsync(user);
return;
} }
} }
else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken)) else if (!string.IsNullOrEmpty(requestModel.SsoEmail2FaSessionToken))

View File

@@ -1,7 +1,5 @@
#nullable enable using Bit.Api.Billing.Models.Responses;
using Bit.Api.Billing.Models.Responses;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -16,6 +14,7 @@ public class AccountsBillingController(
IUserService userService, IUserService userService,
IPaymentHistoryService paymentHistoryService) : Controller IPaymentHistoryService paymentHistoryService) : Controller
{ {
// TODO: Migrate to Query / AccountBillingVNextController
[HttpGet("history")] [HttpGet("history")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingHistoryResponseModel> GetBillingHistoryAsync() public async Task<BillingHistoryResponseModel> GetBillingHistoryAsync()
@@ -30,20 +29,7 @@ public class AccountsBillingController(
return new BillingHistoryResponseModel(billingInfo); return new BillingHistoryResponseModel(billingInfo);
} }
[HttpGet("payment-method")] // TODO: Migrate to Query / AccountBillingVNextController
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<BillingPaymentResponseModel> GetPaymentMethodAsync()
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var billingInfo = await paymentService.GetBillingAsync(user);
return new BillingPaymentResponseModel(billingInfo);
}
[HttpGet("invoices")] [HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null) public async Task<IResult> GetInvoicesAsync([FromQuery] string? status = null, [FromQuery] string? startAfter = null)
{ {
@@ -62,6 +48,7 @@ public class AccountsBillingController(
return TypedResults.Ok(invoices); return TypedResults.Ok(invoices);
} }
// TODO: Migrate to Query / AccountBillingVNextController
[HttpGet("transactions")] [HttpGet("transactions")]
public async Task<IResult> GetTransactionsAsync([FromQuery] DateTime? startAfter = null) public async Task<IResult> GetTransactionsAsync([FromQuery] DateTime? startAfter = null)
{ {
@@ -78,18 +65,4 @@ public class AccountsBillingController(
return TypedResults.Ok(transactions); return TypedResults.Ok(transactions);
} }
[HttpPost("preview-invoice")]
public async Task<IResult> PreviewInvoiceAsync([FromBody] PreviewIndividualInvoiceRequestBody model)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var invoice = await paymentService.PreviewInvoiceAsync(model, user.GatewayCustomerId, user.GatewaySubscriptionId);
return TypedResults.Ok(invoice);
}
} }

View File

@@ -1,6 +1,4 @@
#nullable enable using Bit.Api.Models.Request;
using Bit.Api.Models.Request;
using Bit.Api.Models.Request.Accounts; using Bit.Api.Models.Request.Accounts;
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Utilities; using Bit.Api.Utilities;
@@ -26,8 +24,10 @@ public class AccountsController(
IUserService userService, IUserService userService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
IUserAccountKeysQuery userAccountKeysQuery, IUserAccountKeysQuery userAccountKeysQuery,
IFeatureService featureService) : Controller IFeatureService featureService,
ILicensingService licensingService) : Controller
{ {
// TODO: Remove when pm-24996-implement-upgrade-from-free-dialog is removed
[HttpPost("premium")] [HttpPost("premium")]
public async Task<PaymentResponseModel> PostPremiumAsync( public async Task<PaymentResponseModel> PostPremiumAsync(
PremiumRequestModel model, PremiumRequestModel model,
@@ -75,6 +75,7 @@ public class AccountsController(
}; };
} }
// TODO: Migrate to Query / AccountBillingVNextController as part of Premium -> Organization upgrade work.
[HttpGet("subscription")] [HttpGet("subscription")]
public async Task<SubscriptionResponseModel> GetSubscriptionAsync( public async Task<SubscriptionResponseModel> GetSubscriptionAsync(
[FromServices] GlobalSettings globalSettings, [FromServices] GlobalSettings globalSettings,
@@ -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
@@ -111,29 +114,7 @@ public class AccountsController(
} }
} }
[HttpPost("payment")] // TODO: Migrate to Command / AccountBillingVNextController as PUT /account/billing/vnext/subscription
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostPaymentAsync([FromBody] PaymentRequestModel model)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
await userService.ReplacePaymentMethodAsync(user, model.PaymentToken, model.PaymentMethodType!.Value,
new TaxInfo
{
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressCountry = model.Country,
BillingAddressPostalCode = model.PostalCode,
TaxIdNumber = model.TaxId
});
}
[HttpPost("storage")] [HttpPost("storage")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model) public async Task<PaymentResponseModel> PostStorageAsync([FromBody] StorageRequestModel model)
@@ -148,8 +129,11 @@ public class AccountsController(
return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result }; return new PaymentResponseModel { Success = true, PaymentIntentClientSecret = result };
} }
/*
* TODO: A new version of this exists in the AccountBillingVNextController.
* The individual-self-hosting-license-uploader.component needs to be updated to use it.
* Then, this can be removed.
*/
[HttpPost("license")] [HttpPost("license")]
[SelfHosted(SelfHostedOnly = true)] [SelfHosted(SelfHostedOnly = true)]
public async Task PostLicenseAsync(LicenseRequestModel model) public async Task PostLicenseAsync(LicenseRequestModel model)
@@ -169,6 +153,7 @@ public class AccountsController(
await userService.UpdateLicenseAsync(user, license); await userService.UpdateLicenseAsync(user, license);
} }
// TODO: Migrate to Command / AccountBillingVNextController as DELETE /account/billing/vnext/subscription
[HttpPost("cancel")] [HttpPost("cancel")]
public async Task PostCancelAsync( public async Task PostCancelAsync(
[FromBody] SubscriptionCancellationRequestModel request, [FromBody] SubscriptionCancellationRequestModel request,
@@ -186,6 +171,7 @@ public class AccountsController(
user.IsExpired()); user.IsExpired());
} }
// TODO: Migrate to Command / AccountBillingVNextController as POST /account/billing/vnext/subscription/reinstate
[HttpPost("reinstate-premium")] [HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task PostReinstateAsync() public async Task PostReinstateAsync()
@@ -199,41 +185,6 @@ public class AccountsController(
await userService.ReinstatePremiumAsync(user); await userService.ReinstatePremiumAsync(user);
} }
[HttpGet("tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfoAsync(
[FromServices] IPaymentService paymentService)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var taxInfo = await paymentService.GetTaxInfoAsync(user);
return new TaxInfoResponseModel(taxInfo);
}
[HttpPut("tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfoAsync(
[FromBody] TaxInfoUpdateRequestModel model,
[FromServices] IPaymentService paymentService)
{
var user = await userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var taxInfo = new TaxInfo
{
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
};
await paymentService.SaveTaxInfoAsync(user, taxInfo);
}
private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId) private async Task<IEnumerable<Guid>> GetOrganizationIdsClaimingUserAsync(Guid userId)
{ {
var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId); var organizationsClaimingUser = await userService.GetOrganizationsClaimingUserAsync(userId);

View File

@@ -1,45 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Tax.Requests;
using Bit.Core.Context;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("invoices")]
[Authorize("Application")]
public class InvoicesController : BaseBillingController
{
[HttpPost("preview-organization")]
public async Task<IResult> PreviewInvoiceAsync(
[FromBody] PreviewOrganizationInvoiceRequestBody model,
[FromServices] ICurrentContext currentContext,
[FromServices] IOrganizationRepository organizationRepository,
[FromServices] IPaymentService paymentService)
{
Organization organization = null;
if (model.OrganizationId != default)
{
if (!await currentContext.EditPaymentMethods(model.OrganizationId))
{
return Error.Unauthorized();
}
organization = await organizationRepository.GetByIdAsync(model.OrganizationId);
if (organization == null)
{
return Error.NotFound();
}
}
var invoice = await paymentService.PreviewInvoiceAsync(model, organization?.GatewayCustomerId,
organization?.GatewaySubscriptionId);
return TypedResults.Ok(invoice);
}
}

View File

@@ -1,91 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationConnections.Interfaces;
using Bit.Core.Billing.Models.Business;
using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Models.Api.OrganizationLicenses;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Billing.Controllers;
[Route("licenses")]
[Authorize("Licensing")]
[SelfHosted(NotSelfHostedOnly = true)]
public class LicensesController : Controller
{
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IGetCloudOrganizationLicenseQuery _getCloudOrganizationLicenseQuery;
private readonly IValidateBillingSyncKeyCommand _validateBillingSyncKeyCommand;
private readonly ICurrentContext _currentContext;
public LicensesController(
IUserRepository userRepository,
IUserService userService,
IOrganizationRepository organizationRepository,
IGetCloudOrganizationLicenseQuery getCloudOrganizationLicenseQuery,
IValidateBillingSyncKeyCommand validateBillingSyncKeyCommand,
ICurrentContext currentContext)
{
_userRepository = userRepository;
_userService = userService;
_organizationRepository = organizationRepository;
_getCloudOrganizationLicenseQuery = getCloudOrganizationLicenseQuery;
_validateBillingSyncKeyCommand = validateBillingSyncKeyCommand;
_currentContext = currentContext;
}
[HttpGet("user/{id}")]
public async Task<UserLicense> GetUser(string id, [FromQuery] string key)
{
var user = await _userRepository.GetByIdAsync(new Guid(id));
if (user == null)
{
return null;
}
else if (!user.LicenseKey.Equals(key))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid license key.");
}
var license = await _userService.GenerateLicenseAsync(user, null);
return license;
}
/// <summary>
/// Used by self-hosted installations to get an updated license file
/// </summary>
[HttpGet("organization/{id}")]
public async Task<OrganizationLicense> OrganizationSync(string id, [FromBody] SelfHostedOrganizationLicenseRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(new Guid(id));
if (organization == null)
{
throw new NotFoundException("Organization not found.");
}
if (!organization.LicenseKey.Equals(model.LicenseKey))
{
await Task.Delay(2000);
throw new BadRequestException("Invalid license key.");
}
if (!await _validateBillingSyncKeyCommand.ValidateBillingSyncKeyAsync(organization, model.BillingSyncKey))
{
throw new BadRequestException("Invalid Billing Sync Key");
}
var license = await _getCloudOrganizationLicenseQuery.GetLicenseAsync(organization, _currentContext.InstallationId.Value);
return license;
}
}

View File

@@ -20,9 +20,9 @@ public class OrganizationBillingController(
IOrganizationBillingService organizationBillingService, IOrganizationBillingService organizationBillingService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
IPaymentService paymentService, IPaymentService paymentService,
ISubscriberService subscriberService,
IPaymentHistoryService paymentHistoryService) : BaseBillingController IPaymentHistoryService paymentHistoryService) : BaseBillingController
{ {
// TODO: Remove when pm-25379-use-new-organization-metadata-structure is removed.
[HttpGet("metadata")] [HttpGet("metadata")]
public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId) public async Task<IResult> GetMetadataAsync([FromRoute] Guid organizationId)
{ {
@@ -41,6 +41,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(metadata); return TypedResults.Ok(metadata);
} }
// TODO: Migrate to Query / OrganizationBillingVNextController
[HttpGet("history")] [HttpGet("history")]
public async Task<IResult> GetHistoryAsync([FromRoute] Guid organizationId) public async Task<IResult> GetHistoryAsync([FromRoute] Guid organizationId)
{ {
@@ -61,6 +62,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(billingInfo); return TypedResults.Ok(billingInfo);
} }
// TODO: Migrate to Query / OrganizationBillingVNextController
[HttpGet("invoices")] [HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null) public async Task<IResult> GetInvoicesAsync([FromRoute] Guid organizationId, [FromQuery] string? status = null, [FromQuery] string? startAfter = null)
{ {
@@ -85,6 +87,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(invoices); return TypedResults.Ok(invoices);
} }
// TODO: Migrate to Query / OrganizationBillingVNextController
[HttpGet("transactions")] [HttpGet("transactions")]
public async Task<IResult> GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null) public async Task<IResult> GetTransactionsAsync([FromRoute] Guid organizationId, [FromQuery] DateTime? startAfter = null)
{ {
@@ -108,6 +111,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(transactions); return TypedResults.Ok(transactions);
} }
// TODO: Can be removed once we do away with the organization-plans.component.
[HttpGet] [HttpGet]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> GetBillingAsync(Guid organizationId) public async Task<IResult> GetBillingAsync(Guid organizationId)
@@ -131,127 +135,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }
[HttpGet("payment-method")] // TODO: Migrate to Command / OrganizationBillingVNextController
public async Task<IResult> GetPaymentMethodAsync([FromRoute] Guid organizationId)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var paymentMethod = await subscriberService.GetPaymentMethod(organization);
var response = PaymentMethodResponse.From(paymentMethod);
return TypedResults.Ok(response);
}
[HttpPut("payment-method")]
public async Task<IResult> UpdatePaymentMethodAsync(
[FromRoute] Guid organizationId,
[FromBody] UpdatePaymentMethodRequestBody requestBody)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
var taxInformation = requestBody.TaxInformation.ToDomain();
await organizationBillingService.UpdatePaymentMethod(organization, tokenizedPaymentSource, taxInformation);
return TypedResults.Ok();
}
[HttpPost("payment-method/verify-bank-account")]
public async Task<IResult> VerifyBankAccountAsync(
[FromRoute] Guid organizationId,
[FromBody] VerifyBankAccountRequestBody requestBody)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM"))
{
return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'");
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
await subscriberService.VerifyBankAccount(organization, requestBody.DescriptorCode);
return TypedResults.Ok();
}
[HttpGet("tax-information")]
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid organizationId)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var taxInformation = await subscriberService.GetTaxInformation(organization);
var response = TaxInformationResponse.From(taxInformation);
return TypedResults.Ok(response);
}
[HttpPut("tax-information")]
public async Task<IResult> UpdateTaxInformationAsync(
[FromRoute] Guid organizationId,
[FromBody] TaxInformationRequestBody requestBody)
{
if (!await currentContext.EditPaymentMethods(organizationId))
{
return Error.Unauthorized();
}
var organization = await organizationRepository.GetByIdAsync(organizationId);
if (organization == null)
{
return Error.NotFound();
}
var taxInformation = requestBody.ToDomain();
await subscriberService.UpdateTaxInformation(organization, taxInformation);
return TypedResults.Ok();
}
[HttpPost("setup-business-unit")] [HttpPost("setup-business-unit")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> SetupBusinessUnitAsync( public async Task<IResult> SetupBusinessUnitAsync(
@@ -280,6 +164,7 @@ public class OrganizationBillingController(
return TypedResults.Ok(providerId); return TypedResults.Ok(providerId);
} }
// TODO: Migrate to Command / OrganizationBillingVNextController
[HttpPost("change-frequency")] [HttpPost("change-frequency")]
[SelfHosted(NotSelfHostedOnly = true)] [SelfHosted(NotSelfHostedOnly = true)]
public async Task<IResult> ChangePlanSubscriptionFrequencyAsync( public async Task<IResult> ChangePlanSubscriptionFrequencyAsync(

View File

@@ -19,7 +19,6 @@ using Bit.Core.Billing.Services;
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.Models.Business;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface; using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -67,7 +66,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);
@@ -248,53 +248,6 @@ public class OrganizationsController(
await organizationService.ReinstateSubscriptionAsync(id); await organizationService.ReinstateSubscriptionAsync(id);
} }
[HttpGet("{id:guid}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<TaxInfoResponseModel> GetTaxInfo(Guid id)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = await paymentService.GetTaxInfoAsync(organization);
return new TaxInfoResponseModel(taxInfo);
}
[HttpPut("{id:guid}/tax")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PutTaxInfo(Guid id, [FromBody] ExpandedTaxInfoUpdateRequestModel model)
{
if (!await currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var taxInfo = new TaxInfo
{
TaxIdNumber = model.TaxId,
BillingAddressLine1 = model.Line1,
BillingAddressLine2 = model.Line2,
BillingAddressCity = model.City,
BillingAddressState = model.State,
BillingAddressPostalCode = model.PostalCode,
BillingAddressCountry = model.Country,
};
await paymentService.SaveTaxInfoAsync(organization, taxInfo);
}
/// <summary> /// <summary>
/// Tries to grant owner access to the Secrets Manager for the organization /// Tries to grant owner access to the Secrets Manager for the organization
/// </summary> /// </summary>

View File

@@ -1,7 +1,6 @@
// 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 Bit.Api.Billing.Models.Requests;
using Bit.Api.Billing.Models.Responses; using Bit.Api.Billing.Models.Responses;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Pricing; using Bit.Core.Billing.Pricing;
@@ -9,7 +8,6 @@ using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Repositories; using Bit.Core.Billing.Providers.Repositories;
using Bit.Core.Billing.Providers.Services; using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Billing.Tax.Models;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Models.BitStripe; using Bit.Core.Models.BitStripe;
using Bit.Core.Services; using Bit.Core.Services;
@@ -34,6 +32,7 @@ public class ProviderBillingController(
IStripeAdapter stripeAdapter, IStripeAdapter stripeAdapter,
IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService) IUserService userService) : BaseProviderController(currentContext, logger, providerRepository, userService)
{ {
// TODO: Migrate to Query / ProviderBillingVNextController
[HttpGet("invoices")] [HttpGet("invoices")]
public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId) public async Task<IResult> GetInvoicesAsync([FromRoute] Guid providerId)
{ {
@@ -54,6 +53,7 @@ public class ProviderBillingController(
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }
// TODO: Migrate to Query / ProviderBillingVNextController
[HttpGet("invoices/{invoiceId}")] [HttpGet("invoices/{invoiceId}")]
public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId) public async Task<IResult> GenerateClientInvoiceReportAsync([FromRoute] Guid providerId, string invoiceId)
{ {
@@ -76,51 +76,7 @@ public class ProviderBillingController(
"text/csv"); "text/csv");
} }
[HttpPut("payment-method")] // TODO: Migrate to Query / ProviderBillingVNextController
public async Task<IResult> UpdatePaymentMethodAsync(
[FromRoute] Guid providerId,
[FromBody] UpdatePaymentMethodRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
var tokenizedPaymentSource = requestBody.PaymentSource.ToDomain();
var taxInformation = requestBody.TaxInformation.ToDomain();
await providerBillingService.UpdatePaymentMethod(
provider,
tokenizedPaymentSource,
taxInformation);
return TypedResults.Ok();
}
[HttpPost("payment-method/verify-bank-account")]
public async Task<IResult> VerifyBankAccountAsync(
[FromRoute] Guid providerId,
[FromBody] VerifyBankAccountRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
if (requestBody.DescriptorCode.Length != 6 || !requestBody.DescriptorCode.StartsWith("SM"))
{
return Error.BadRequest("Statement descriptor should be a 6-character value that starts with 'SM'");
}
await subscriberService.VerifyBankAccount(provider, requestBody.DescriptorCode);
return TypedResults.Ok();
}
[HttpGet("subscription")] [HttpGet("subscription")]
public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId) public async Task<IResult> GetSubscriptionAsync([FromRoute] Guid providerId)
{ {
@@ -172,53 +128,4 @@ public class ProviderBillingController(
return TypedResults.Ok(response); return TypedResults.Ok(response);
} }
[HttpGet("tax-information")]
public async Task<IResult> GetTaxInformationAsync([FromRoute] Guid providerId)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
var taxInformation = await subscriberService.GetTaxInformation(provider);
var response = TaxInformationResponse.From(taxInformation);
return TypedResults.Ok(response);
}
[HttpPut("tax-information")]
public async Task<IResult> UpdateTaxInformationAsync(
[FromRoute] Guid providerId,
[FromBody] TaxInformationRequestBody requestBody)
{
var (provider, result) = await TryGetBillableProviderForAdminOperation(providerId);
if (provider == null)
{
return result;
}
if (requestBody is not { Country: not null, PostalCode: not null })
{
return Error.BadRequest("Country and postal code are required to update your tax information.");
}
var taxInformation = new TaxInformation(
requestBody.Country,
requestBody.PostalCode,
requestBody.TaxId,
requestBody.TaxIdType,
requestBody.Line1,
requestBody.Line2,
requestBody.City,
requestBody.State);
await subscriberService.UpdateTaxInformation(provider, taxInformation);
return TypedResults.Ok();
}
} }

View File

@@ -1,5 +1,4 @@
#nullable enable using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Attributes;
using Bit.Api.Billing.Models.Requests.Premium; using Bit.Api.Billing.Models.Requests.Premium;
using Bit.Api.Utilities; using Bit.Api.Utilities;
using Bit.Core; using Bit.Core;
@@ -17,7 +16,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
[Authorize("Application")] [Authorize("Application")]
[Route("account/billing/vnext/self-host")] [Route("account/billing/vnext/self-host")]
[SelfHosted(SelfHostedOnly = true)] [SelfHosted(SelfHostedOnly = true)]
public class SelfHostedAccountBillingController( public class SelfHostedAccountBillingVNextController(
ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController ICreatePremiumSelfHostedSubscriptionCommand createPremiumSelfHostedSubscriptionCommand) : BaseBillingController
{ {
[HttpPost("license")] [HttpPost("license")]

View File

@@ -14,7 +14,7 @@ namespace Bit.Api.Billing.Controllers.VNext;
[Authorize("Application")] [Authorize("Application")]
[Route("organizations/{organizationId:guid}/billing/vnext/self-host")] [Route("organizations/{organizationId:guid}/billing/vnext/self-host")]
[SelfHosted(SelfHostedOnly = true)] [SelfHosted(SelfHostedOnly = true)]
public class SelfHostedBillingController( public class SelfHostedOrganizationBillingVNextController(
IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController IGetOrganizationMetadataQuery getOrganizationMetadataQuery) : BaseBillingController
{ {
[Authorize<MemberOrProviderRequirement>] [Authorize<MemberOrProviderRequirement>]

View File

@@ -1,31 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Requests;
public class TaxInformationRequestBody
{
[Required]
public string Country { get; set; }
[Required]
public string PostalCode { get; set; }
public string TaxId { get; set; }
public string TaxIdType { get; set; }
public string Line1 { get; set; }
public string Line2 { get; set; }
public string City { get; set; }
public string State { get; set; }
public TaxInformation ToDomain() => new(
Country,
PostalCode,
TaxId,
TaxIdType,
Line1,
Line2,
City,
State);
}

View File

@@ -1,25 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Api.Utilities;
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
namespace Bit.Api.Billing.Models.Requests;
public class TokenizedPaymentSourceRequestBody
{
[Required]
[EnumMatches<PaymentMethodType>(
PaymentMethodType.BankAccount,
PaymentMethodType.Card,
PaymentMethodType.PayPal,
ErrorMessage = "'type' must be BankAccount, Card or PayPal")]
public PaymentMethodType Type { get; set; }
[Required]
public string Token { get; set; }
public TokenizedPaymentSource ToDomain() => new(Type, Token);
}

View File

@@ -1,15 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests;
public class UpdatePaymentMethodRequestBody
{
[Required]
public TokenizedPaymentSourceRequestBody PaymentSource { get; set; }
[Required]
public TaxInformationRequestBody TaxInformation { get; set; }
}

View File

@@ -1,12 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
namespace Bit.Api.Billing.Models.Requests;
public class VerifyBankAccountRequestBody
{
[Required]
public string DescriptorCode { get; set; }
}

View File

@@ -1,20 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.Billing.Models;
using Bit.Core.Models.Api;
namespace Bit.Api.Billing.Models.Responses;
public class BillingPaymentResponseModel : ResponseModel
{
public BillingPaymentResponseModel(BillingInfo billing)
: base("billingPayment")
{
Balance = billing.Balance;
PaymentSource = billing.PaymentSource != null ? new BillingSource(billing.PaymentSource) : null;
}
public decimal Balance { get; set; }
public BillingSource PaymentSource { get; set; }
}

View File

@@ -1,18 +0,0 @@
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;
public record PaymentMethodResponse(
decimal AccountCredit,
PaymentSource PaymentSource,
string SubscriptionStatus,
TaxInformation TaxInformation)
{
public static PaymentMethodResponse From(PaymentMethod paymentMethod) =>
new(
paymentMethod.AccountCredit,
paymentMethod.PaymentSource,
paymentMethod.SubscriptionStatus,
paymentMethod.TaxInformation);
}

View File

@@ -1,16 +0,0 @@
using Bit.Core.Billing.Models;
using Bit.Core.Enums;
namespace Bit.Api.Billing.Models.Responses;
public record PaymentSourceResponse(
PaymentMethodType Type,
string Description,
bool NeedsVerification)
{
public static PaymentSourceResponse From(PaymentSource paymentMethod)
=> new(
paymentMethod.Type,
paymentMethod.Description,
paymentMethod.NeedsVerification);
}

View File

@@ -1,23 +0,0 @@
using Bit.Core.Billing.Tax.Models;
namespace Bit.Api.Billing.Models.Responses;
public record TaxInformationResponse(
string Country,
string PostalCode,
string TaxId,
string Line1,
string Line2,
string City,
string State)
{
public static TaxInformationResponse From(TaxInformation taxInformation)
=> new(
taxInformation.Country,
taxInformation.PostalCode,
taxInformation.TaxId,
taxInformation.Line1,
taxInformation.Line2,
taxInformation.City,
taxInformation.State);
}

View File

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

View File

@@ -1,8 +1,8 @@
#nullable enable using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.Auth.Models.Request; using Bit.Api.Auth.Models.Request;
using Bit.Api.Auth.Models.Request.WebAuthn; using Bit.Api.Auth.Models.Request.WebAuthn;
using Bit.Api.KeyManagement.Models.Requests; using Bit.Api.KeyManagement.Models.Requests;
using Bit.Api.KeyManagement.Models.Responses;
using Bit.Api.KeyManagement.Validators; using Bit.Api.KeyManagement.Validators;
using Bit.Api.Tools.Models.Request; using Bit.Api.Tools.Models.Request;
using Bit.Api.Vault.Models.Request; using Bit.Api.Vault.Models.Request;
@@ -14,6 +14,7 @@ using Bit.Core.Entities;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
using Bit.Core.KeyManagement.Commands.Interfaces; using Bit.Core.KeyManagement.Commands.Interfaces;
using Bit.Core.KeyManagement.Models.Data; using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.KeyManagement.Queries.Interfaces;
using Bit.Core.KeyManagement.UserKey; using Bit.Core.KeyManagement.UserKey;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
@@ -45,11 +46,13 @@ public class AccountsKeyManagementController : Controller
private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>> private readonly IRotationValidator<IEnumerable<WebAuthnLoginRotateKeyRequestModel>, IEnumerable<WebAuthnLoginRotateKeyData>>
_webauthnKeyValidator; _webauthnKeyValidator;
private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator; private readonly IRotationValidator<IEnumerable<OtherDeviceKeysUpdateRequestModel>, IEnumerable<Device>> _deviceValidator;
private readonly IKeyConnectorConfirmationDetailsQuery _keyConnectorConfirmationDetailsQuery;
public AccountsKeyManagementController(IUserService userService, public AccountsKeyManagementController(IUserService userService,
IFeatureService featureService, IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IEmergencyAccessRepository emergencyAccessRepository, IEmergencyAccessRepository emergencyAccessRepository,
IKeyConnectorConfirmationDetailsQuery keyConnectorConfirmationDetailsQuery,
IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand, IRegenerateUserAsymmetricKeysCommand regenerateUserAsymmetricKeysCommand,
IRotateUserAccountKeysCommand rotateUserKeyCommandV2, IRotateUserAccountKeysCommand rotateUserKeyCommandV2,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator, IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
@@ -75,12 +78,13 @@ public class AccountsKeyManagementController : Controller
_organizationUserValidator = organizationUserValidator; _organizationUserValidator = organizationUserValidator;
_webauthnKeyValidator = webAuthnKeyValidator; _webauthnKeyValidator = webAuthnKeyValidator;
_deviceValidator = deviceValidator; _deviceValidator = deviceValidator;
_keyConnectorConfirmationDetailsQuery = keyConnectorConfirmationDetailsQuery;
} }
[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();
} }
@@ -178,4 +182,17 @@ public class AccountsKeyManagementController : Controller
throw new BadRequestException(ModelState); throw new BadRequestException(ModelState);
} }
[HttpGet("key-connector/confirmation-details/{orgSsoIdentifier}")]
public async Task<KeyConnectorConfirmationDetailsResponseModel> GetKeyConnectorConfirmationDetailsAsync(string orgSsoIdentifier)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var details = await _keyConnectorConfirmationDetailsQuery.Run(orgSsoIdentifier, user.Id);
return new KeyConnectorConfirmationDetailsResponseModel(details);
}
} }

View File

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

View File

@@ -0,0 +1,24 @@
using Bit.Core.KeyManagement.Models.Data;
using Bit.Core.Models.Api;
namespace Bit.Api.KeyManagement.Models.Responses;
public class KeyConnectorConfirmationDetailsResponseModel : ResponseModel
{
private const string _objectName = "keyConnectorConfirmationDetails";
public KeyConnectorConfirmationDetailsResponseModel(KeyConnectorConfirmationDetails details,
string obj = _objectName) : base(obj)
{
ArgumentNullException.ThrowIfNull(details);
OrganizationName = details.OrganizationName;
}
public KeyConnectorConfirmationDetailsResponseModel() : base(_objectName)
{
OrganizationName = string.Empty;
}
public string OrganizationName { get; set; }
}

View File

@@ -1,4 +1,7 @@
using Bit.Core.Billing.Constants; using System.Security.Claims;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Models.Business; using Bit.Core.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")
{ {

View File

@@ -1,9 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core.Utilities;
#nullable disable
using AspNetCoreRateLimit;
using Bit.Core.Utilities;
using Microsoft.IdentityModel.Tokens;
namespace Bit.Api; namespace Bit.Api;
@@ -17,32 +12,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder => .ConfigureWebHostDefaults(webBuilder =>
{ {
webBuilder.UseStartup<Startup>(); webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Exception != null &&
(e.Exception.GetType() == typeof(SecurityTokenValidationException) ||
e.Exception.Message == "Bad security stamp."))
{
return false;
}
if (
context.Contains(typeof(IpRateLimitMiddleware).FullName))
{
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IpRateLimit;
}
if (context.Contains("Duende.IdentityServer.Validation.TokenValidator") ||
context.Contains("Duende.IdentityServer.Validation.TokenRequestValidator"))
{
return e.Level >= globalSettings.MinLogLevel.ApiSettings.IdentityToken;
}
return e.Level >= globalSettings.MinLogLevel.ApiSettings.Default;
}));
}) })
.AddSerilogFileLogging()
.Build() .Build()
.Run(); .Run();
} }

View File

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

View File

@@ -8,6 +8,7 @@ using Bit.Core.Auth.Identity;
using Bit.Core.Context; using Bit.Core.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);

View File

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

View File

@@ -28,6 +28,8 @@ public class SecretUpdateRequestModel : IValidatableObject
public SecretAccessPoliciesRequestsModel AccessPoliciesRequests { get; set; } public 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;

View File

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

View File

@@ -216,7 +216,7 @@ public class Startup
config.Conventions.Add(new PublicApiControllersModelConvention()); 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);
} }
@@ -234,12 +235,10 @@ public class Startup
public void Configure( public void Configure(
IApplicationBuilder app, IApplicationBuilder app,
IWebHostEnvironment env, IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings, GlobalSettings globalSettings,
ILogger<Startup> logger) ILogger<Startup> logger)
{ {
IdentityModelEventSource.ShowPII = true; IdentityModelEventSource.ShowPII = true;
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers // Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>(); app.UseMiddleware<SecurityHeadersMiddleware>();
@@ -294,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";

View File

@@ -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();

View File

@@ -757,15 +757,10 @@ 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();
await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher), new Guid(model.Cipher.OrganizationId), await _cipherService.ShareAsync(original, model.Cipher.ToCipher(cipher, user.Id), new Guid(model.Cipher.OrganizationId),
model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate); model.CollectionIds.Select(c => new Guid(c)), user.Id, model.Cipher.LastKnownRevisionDate);
var sharedCipher = await GetByIdAsync(id, user.Id); var sharedCipher = await GetByIdAsync(id, user.Id);
@@ -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));
} }

View File

@@ -84,7 +84,7 @@ public class CipherRequestModel
return existingCipher; return existingCipher;
} }
public Cipher ToCipher(Cipher existingCipher) public Cipher ToCipher(Cipher existingCipher, Guid? userId = null)
{ {
// If Data field is provided, use it directly // If Data field is provided, use it directly
if (!string.IsNullOrWhiteSpace(Data)) if (!string.IsNullOrWhiteSpace(Data))
@@ -124,9 +124,12 @@ public class CipherRequestModel
} }
} }
var userIdKey = userId.HasValue ? userId.ToString().ToUpperInvariant() : null;
existingCipher.Reprompt = Reprompt; existingCipher.Reprompt = Reprompt;
existingCipher.Key = Key; existingCipher.Key = Key;
existingCipher.ArchivedDate = ArchivedDate; existingCipher.ArchivedDate = ArchivedDate;
existingCipher.Folders = UpdateUserSpecificJsonField(existingCipher.Folders, userIdKey, FolderId);
existingCipher.Favorites = UpdateUserSpecificJsonField(existingCipher.Favorites, userIdKey, Favorite);
var hasAttachments2 = (Attachments2?.Count ?? 0) > 0; var hasAttachments2 = (Attachments2?.Count ?? 0) > 0;
var hasAttachments = (Attachments?.Count ?? 0) > 0; var hasAttachments = (Attachments?.Count ?? 0) > 0;
@@ -291,6 +294,37 @@ public class CipherRequestModel
KeyFingerprint = SSHKey.KeyFingerprint, KeyFingerprint = SSHKey.KeyFingerprint,
}; };
} }
/// <summary>
/// Updates a JSON string representing a dictionary by adding, updating, or removing a key-value pair
/// based on the provided userIdKey and newValue.
/// </summary>
private static string UpdateUserSpecificJsonField(string existingJson, string userIdKey, object newValue)
{
if (userIdKey == null)
{
return existingJson;
}
var jsonDict = string.IsNullOrWhiteSpace(existingJson)
? new Dictionary<string, object>()
: JsonSerializer.Deserialize<Dictionary<string, object>>(existingJson) ?? new Dictionary<string, object>();
var shouldRemove = newValue == null ||
(newValue is string strValue && string.IsNullOrWhiteSpace(strValue)) ||
(newValue is bool boolValue && !boolValue);
if (shouldRemove)
{
jsonDict.Remove(userIdKey);
}
else
{
jsonDict[userIdKey] = newValue is string str ? str.ToUpperInvariant() : newValue;
}
return jsonDict.Count == 0 ? null : JsonSerializer.Serialize(jsonDict);
}
} }
public class CipherWithIdRequestModel : CipherRequestModel public class CipherWithIdRequestModel : CipherRequestModel

View File

@@ -41,6 +41,7 @@
"phishingDomain": { "phishingDomain": {
"updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt", "updateUrl": "https://phish.co.za/latest/phishing-domains-ACTIVE.txt",
"checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256" "checksumUrl": "https://raw.githubusercontent.com/Phishing-Database/checksums/refs/heads/master/phishing-domains-ACTIVE.txt.sha256"
} },
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
} }
} }

View File

@@ -32,9 +32,6 @@
"send": { "send": {
"connectionString": "SECRET" "connectionString": "SECRET"
}, },
"sentry": {
"dsn": "SECRET"
},
"notificationHub": { "notificationHub": {
"connectionString": "SECRET", "connectionString": "SECRET",
"hubName": "SECRET" "hubName": "SECRET"

View File

@@ -11,7 +11,7 @@
<ProjectReference Include="..\Core\Core.csproj" /> <ProjectReference Include="..\Core\Core.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="MarkDig" Version="0.41.3" /> <PackageReference Include="MarkDig" Version="0.44.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.4" />
</ItemGroup> </ItemGroup>

View File

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

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