1
0
mirror of https://github.com/bitwarden/server synced 2026-01-26 22:33:31 +00:00

Merge branch 'main' into renovate/anglesharp-1.x

This commit is contained in:
Nick Krantz
2026-01-23 10:24:24 -06:00
committed by GitHub
1164 changed files with 132465 additions and 15659 deletions

View File

@@ -1,25 +0,0 @@
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.

View File

@@ -71,10 +71,10 @@ dotnet_naming_symbols.any_async_methods.applicable_kinds = method
dotnet_naming_symbols.any_async_methods.applicable_accessibilities = *
dotnet_naming_symbols.any_async_methods.required_modifiers = async
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_prefix =
dotnet_naming_style.end_in_async.required_suffix = Async
dotnet_naming_style.end_in_async.capitalization = pascal_case
dotnet_naming_style.end_in_async.word_separator =
dotnet_naming_style.end_in_async.word_separator =
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
dotnet_diagnostic.CS0618.severity = suggestion
@@ -85,6 +85,12 @@ dotnet_diagnostic.CS0612.severity = suggestion
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
dotnet_diagnostic.IDE0005.severity = warning
# Specify CultureInfo https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1304
dotnet_diagnostic.CA1304.severity = warning
# Specify IFormatProvider https://learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1305
dotnet_diagnostic.CA1305.severity = warning
# CSharp code style settings:
[*.cs]
# Prefer "var" everywhere

8
.github/CODEOWNERS vendored
View File

@@ -36,6 +36,7 @@ util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
# UIF
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 @bitwarden/team-auth-dev
@@ -52,6 +53,11 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
# Dirt (Data Insights & Reporting) team
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
src/Events @bitwarden/team-data-insights-and-reporting-dev
src/EventsProcessor @bitwarden/team-data-insights-and-reporting-dev
test/Events.IntegrationTest @bitwarden/team-data-insights-and-reporting-dev
test/Events.Test @bitwarden/team-data-insights-and-reporting-dev
test/EventsProcessor.Test @bitwarden/team-data-insights-and-reporting-dev
# Vault team
**/Vault @bitwarden/team-vault-dev
@@ -62,8 +68,6 @@ src/Core/IdentityServer @bitwarden/team-auth-dev
bitwarden_license/src/Scim @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.IntegrationTest @bitwarden/team-admin-console-dev
bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
src/Events @bitwarden/team-admin-console-dev
src/EventsProcessor @bitwarden/team-admin-console-dev
# Billing team
**/*billing* @bitwarden/team-billing-dev

View File

@@ -1,4 +1,4 @@
name: Bitwarden Lite Deployment Bug Report
name: Bitwarden lite Deployment Bug Report
description: File a bug report
labels: [bug, bw-lite-deploy]
body:
@@ -70,15 +70,6 @@ body:
mariadb:10
# Postgres Example
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
id: issue-tracking-info
attributes:

122
.github/renovate.json5 vendored
View File

@@ -10,41 +10,7 @@
"nuget",
],
packageRules: [
{
groupName: "cargo minor",
matchManagers: ["cargo"],
matchUpdateTypes: ["minor"],
},
{
groupName: "dockerfile minor",
matchManagers: ["dockerfile"],
matchUpdateTypes: ["minor"],
},
{
groupName: "docker-compose minor",
matchManagers: ["docker-compose"],
matchUpdateTypes: ["minor"],
},
{
groupName: "github-action minor",
matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"],
addLabels: ["hold"],
},
{
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
// This overrides the default that ignores patch updates for nuget dependencies.
matchPackageNames: [
"/^Microsoft\\.Extensions\\./",
"/^Microsoft\\.AspNetCore\\./",
],
matchUpdateTypes: ["patch"],
dependencyDashboardApproval: false,
},
{
matchSourceUrls: ["https://github.com/bitwarden/sdk-internal"],
groupName: "sdk-internal",
},
// ==================== Team Ownership Rules ====================
{
matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:",
@@ -63,11 +29,11 @@
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.Extensions.Caching.Cosmos",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
@@ -90,11 +56,7 @@
"Microsoft.AspNetCore.Mvc.Testing",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
@@ -105,11 +67,6 @@
commitMessagePrefix: "[deps] Billing:",
reviewers: ["team:team-billing-dev"],
},
{
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
groupName: "EntityFrameworkCore",
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"Dapper",
@@ -141,6 +98,7 @@
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
@@ -156,7 +114,6 @@
"Microsoft.Extensions.DependencyInjection",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Microsoft.Extensions.Caching.Cosmos",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Quartz",
@@ -165,6 +122,12 @@
commitMessagePrefix: "[deps] Platform:",
reviewers: ["team:team-platform-dev"],
},
{
matchUpdateTypes: ["lockFileMaintenance"],
description: "Platform owns lock file maintenance",
commitMessagePrefix: "[deps] Platform:",
reviewers: ["team:team-platform-dev"],
},
{
matchPackageNames: [
"AutoMapper.Extensions.Microsoft.DependencyInjection",
@@ -194,6 +157,73 @@
commitMessagePrefix: "[deps] Vault:",
reviewers: ["team:team-vault-dev"],
},
// ==================== Grouping Rules ====================
// These come after any specific team assignment rules to ensure
// that grouping is not overridden by subsequent rule definitions.
{
groupName: "cargo minor",
matchManagers: ["cargo"],
matchUpdateTypes: ["minor"],
},
{
groupName: "dockerfile minor",
matchManagers: ["dockerfile"],
matchUpdateTypes: ["minor"],
},
{
groupName: "docker-compose minor",
matchManagers: ["docker-compose"],
matchUpdateTypes: ["minor"],
},
{
groupName: "github-action minor",
matchManagers: ["github-actions"],
matchUpdateTypes: ["minor"],
addLabels: ["hold"],
},
{
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
groupName: "EntityFrameworkCore",
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
groupName: "sdk-internal",
dependencyDashboardApproval: true
},
// ==================== Dashboard Rules ====================
{
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
// This overrides the default that ignores patch updates for nuget dependencies.
matchPackageNames: [
"/^Microsoft\\.Extensions\\./",
"/^Microsoft\\.AspNetCore\\./",
],
matchUpdateTypes: ["patch"],
dependencyDashboardApproval: false,
},
{
// For the Platform-owned dependencies below, we have decided we will only be creating PRs
// for major updates, and sending minor (as well as patch, inherited from base config) to the dashboard.
// This rule comes AFTER grouping rules so that groups are respected while still
// sending minor/patch updates to the dependency dashboard for approval.
matchPackageNames: [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"LaunchDarkly.ServerSdk",
"Quartz",
],
matchUpdateTypes: ["minor"],
dependencyDashboardApproval: true,
},
],
ignoreDeps: ["dotnet-sdk"],
}

View File

@@ -38,7 +38,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
@@ -68,7 +68,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: true

View File

@@ -25,13 +25,13 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Verify format
run: dotnet format --verify-no-changes
@@ -39,8 +39,7 @@ jobs:
build-artifacts:
name: Build Docker images
runs-on: ubuntu-22.04
needs:
- lint
needs: lint
outputs:
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
permissions:
@@ -102,7 +101,7 @@ jobs:
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
@@ -120,10 +119,10 @@ jobs:
fi
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Set up Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -160,7 +159,7 @@ jobs:
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
if: ${{ matrix.dotnet }}
with:
name: ${{ matrix.project_name }}.zip
@@ -169,10 +168,10 @@ jobs:
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
########## ACRs ##########
- name: Log in to Azure
@@ -185,13 +184,6 @@ jobs:
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
@@ -250,12 +242,10 @@ jobs:
linux/arm64
push: true
tags: ${{ steps.image-tags.outputs.tags }}
secrets: |
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
- name: Install Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
uses: sigstore/cosign-installer@7e8b541eb2e61bf99390e1afd4be13a184e9ebc5 # v3.10.1
- name: Sign image with Cosign
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
@@ -273,14 +263,14 @@ jobs:
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.0.0
uses: anchore/scan-action@3c9a191a0fbab285ca6b8530b5de5a642cba332f # v7.2.2
with:
image: ${{ steps.image-tags.outputs.primary_tag }}
fail-build: false
output-format: sarif
- name: Upload Grype results to GitHub
uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/upload-sarif@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
@@ -298,13 +288,13 @@ jobs:
actions: read
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
@@ -365,7 +355,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
@@ -375,7 +365,7 @@ jobs:
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
@@ -387,21 +377,21 @@ jobs:
pwsh ./generate_openapi_files.ps1
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: swagger.json
path: api.public.json
if-no-files-found: error
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: internal.json
path: api.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: identity.json
path: identity.json
@@ -410,8 +400,7 @@ jobs:
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
runs-on: ubuntu-22.04
needs:
- lint
needs: lint
defaults:
run:
shell: bash
@@ -425,13 +414,13 @@ jobs:
- win-x64
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment
run: |
@@ -447,7 +436,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@@ -455,20 +444,19 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
if-no-files-found: error
self-host-build:
name: Trigger self-host build
bitwarden-lite-build:
name: Trigger Bitwarden lite build
if: |
github.event_name != 'pull_request'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04
needs:
- build-artifacts
needs: build-artifacts
permissions:
id-token: write
steps:
@@ -479,20 +467,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger Bitwarden Lite build
- name: Generate GH App token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: self-host
- name: Trigger Bitwarden lite build
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
@@ -505,11 +502,10 @@ jobs:
});
trigger-k8s-deploy:
name: Trigger k8s deploy
name: Trigger K8s deploy
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs:
- build-artifacts
needs: build-artifacts
permissions:
id-token: write
steps:
@@ -520,20 +516,29 @@ jobs:
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Retrieve GitHub PAT secrets
id: retrieve-secret-pat
- name: Get Azure Key Vault secrets
id: get-kv-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
keyvault: gh-org-bitwarden
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main
- name: Trigger k8s deploy
- name: Generate GH App token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
owner: ${{ github.repository_owner }}
repositories: devops
- name: Trigger K8s deploy
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
github-token: ${{ steps.app-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
@@ -548,8 +553,7 @@ jobs:
setup-ephemeral-environment:
name: Setup Ephemeral Environment
needs:
- build-artifacts
needs: build-artifacts
if: |
needs.build-artifacts.outputs.has_secrets == 'true'
&& github.event_name == 'pull_request'
@@ -572,7 +576,7 @@ jobs:
- build-artifacts
- upload
- build-mssqlmigratorutility
- self-host-build
- bitwarden-lite-build
- trigger-k8s-deploy
permissions:
id-token: write

View File

@@ -1,71 +0,0 @@
name: Container registry cleanup
on:
pull_request:
types: [closed]
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
build-docker:
name: Remove branch-specific Docker images
runs-on: ubuntu-22.04
permissions:
id-token: write
steps:
- name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main
with:
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
########## Remove Docker images ##########
- name: Remove the Docker image from ACR
env:
REF: ${{ github.event.pull_request.head.ref }}
SERVICES: |
services:
- Admin
- Api
- Attachments
- Events
- EventsProcessor
- Icons
- Identity
- K8S-Proxy
- MsSql
- Nginx
- Notifications
- Server
- Setup
- Sso
run: |
for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
do
SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}')
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$(
az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
| jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
)
if [[ "$TAG_EXISTS" == "true" ]]; then
echo "[*] Tag exists. Removing tag"
az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes
else
echo "[*] Tag does not exist. No action needed"
fi
done
- name: Log out of Docker
run: docker logout
- name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main

View File

@@ -31,7 +31,7 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Checkout main
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@@ -36,7 +36,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -59,7 +59,7 @@ jobs:
- name: Collect
id: collect
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
uses: launchdarkly/find-code-references@89a7d362d1d4b3725fe0fe0ccd0dc69e3bdcba58 # v2.14.0
with:
accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
projKey: default

View File

@@ -87,7 +87,7 @@ jobs:
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
@@ -95,7 +95,7 @@ jobs:
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
- name: Run k6 tests
uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0
uses: grafana/run-k6-action@a15e2072ede004e8d46141e33d7f7dad8ad08d9d # v1.3.1
continue-on-error: false
env:
K6_OTEL_METRIC_PREFIX: k6_

View File

@@ -31,7 +31,7 @@ jobs:
label: "DB-migrations-changed"
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 2
persist-credentials: false

View File

@@ -91,7 +91,6 @@ jobs:
- project_name: Nginx
- project_name: Notifications
- project_name: Scim
- project_name: Server
- project_name: Setup
- project_name: Sso
steps:
@@ -106,7 +105,7 @@ jobs:
echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false

View File

@@ -39,7 +39,7 @@ jobs:
fi
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
persist-credentials: false
@@ -89,7 +89,7 @@ jobs:
- name: Create release
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-EU.zip,

View File

@@ -22,9 +22,7 @@ on:
required: false
type: string
permissions:
pull-requests: write
contents: write
permissions: {}
jobs:
setup:
@@ -32,6 +30,7 @@ jobs:
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
permissions: {}
steps:
- name: Set branch
id: set-branch
@@ -84,14 +83,15 @@ jobs:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
@@ -207,14 +207,15 @@ jobs:
uses: bitwarden/gh-actions/azure-logout@main
- name: Generate GH App token
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
id: app-token
with:
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
permission-contents: write
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
@@ -240,10 +241,5 @@ jobs:
move_edd_db_scripts:
name: Move EDD database scripts
needs: cut_branch
permissions:
actions: read
contents: write
id-token: write
pull-requests: write
permissions: {}
uses: ./.github/workflows/_move_edd_db_scripts.yml
secrets: inherit

View File

@@ -2,7 +2,7 @@ name: Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened]
permissions: {}

View File

@@ -15,7 +15,7 @@ jobs:
pull-requests: write
steps:
- name: Check
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes"

View File

@@ -44,12 +44,12 @@ jobs:
checks: write
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Restore tools
run: dotnet tool restore
@@ -62,7 +62,7 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
- name: Add MariaDB for Bitwarden Lite
- name: Add MariaDB for Bitwarden lite
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
@@ -133,7 +133,7 @@ jobs:
# Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
# Bitwarden Lite MariaDB
# Bitwarden lite MariaDB
BW_TEST_DATABASES__4__TYPE: "MySql"
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
@@ -156,7 +156,7 @@ jobs:
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -165,7 +165,7 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
- name: Docker Compose down
if: always()
@@ -178,12 +178,12 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Print environment
run: |
@@ -197,7 +197,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: sql.dacpac
path: Sql.dacpac
@@ -223,7 +223,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: report.xml
path: |
@@ -262,3 +262,26 @@ jobs:
working-directory: "dev"
run: docker compose down
shell: pwsh
validate-migration-naming:
name: Validate new migration naming and order
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
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

@@ -27,20 +27,20 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5.1.0
- name: Install rust
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
uses: dtolnay/rust-toolchain@f7ccc83f9ed1e5b9c81d8a67d7ad1a747e22a561 # stable
with:
toolchain: stable
- name: Cache cargo registry
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
- name: Print environment
run: |
@@ -59,7 +59,7 @@ jobs:
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
@@ -68,4 +68,4 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2

1
.gitignore vendored
View File

@@ -234,6 +234,7 @@ bitwarden_license/src/Sso/Sso.zip
/identity.json
/api.json
/api.public.json
.serena/
# Serena
.serena/

84
.vscode/launch.json vendored
View File

@@ -69,6 +69,28 @@
"preLaunchTask": "buildFullServer",
"stopAll": true
},
{
"name": "Full Server with Seeder API",
"configurations": [
"run-Admin",
"run-API",
"run-Events",
"run-EventsProcessor",
"run-Identity",
"run-Sso",
"run-Icons",
"run-Billing",
"run-Notifications",
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 6
},
"preLaunchTask": "buildFullServerWithSeederApi",
"stopAll": true
},
{
"name": "Self Host: Bit",
"configurations": [
@@ -204,6 +226,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API",
"configurations": [
"run-SeederAPI"
],
"presentation": {
"hidden": false,
"group": "cloud",
},
"preLaunchTask": "buildSeederAPI",
},
{
"name": "Admin Self Host",
"configurations": [
@@ -270,6 +303,17 @@
},
"preLaunchTask": "buildSso",
},
{
"name": "Seeder API Self Host",
"configurations": [
"run-SeederAPI-SelfHost"
],
"presentation": {
"hidden": false,
"group": "self-host",
},
"preLaunchTask": "buildSeederAPI",
}
],
"configurations": [
// Configurations represent run-only scenarios so that they can be used in multiple compounds
@@ -311,6 +355,25 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Billing",
"presentation": {
@@ -488,6 +551,27 @@
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-SeederAPI-SelfHost",
"presentation": {
"hidden": true,
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/util/SeederApi/bin/Debug/net8.0/SeederApi.dll",
"args": [],
"cwd": "${workspaceFolder}/util/SeederApi",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:5048",
"developSelfHosted": "true",
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "run-Admin-SelfHost",
"presentation": {

69
.vscode/tasks.json vendored
View File

@@ -43,6 +43,21 @@
"label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
"buildEventsProcessor",
"buildIdentity",
"buildSso",
"buildIcons",
"buildBilling",
"buildNotifications"
],
},
{
"label": "buildFullServerWithSeederApi",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
"buildAPI",
@@ -52,6 +67,7 @@
"buildIcons",
"buildBilling",
"buildNotifications",
"buildSeederAPI"
],
},
{
@@ -89,6 +105,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -102,6 +121,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -115,6 +137,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -128,6 +153,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -141,6 +169,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -154,6 +185,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -167,6 +201,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile"
},
{
@@ -180,6 +217,29 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildSeederAPI",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/util/SeederApi/SeederApi.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -197,6 +257,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -214,6 +277,9 @@
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"options": {
"cwd": "${workspaceFolder}"
},
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
@@ -224,6 +290,9 @@
"label": "test",
"type": "shell",
"command": "dotnet test",
"options": {
"cwd": "${workspaceFolder}"
},
"group": {
"kind": "test",
"isDefault": true

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.11.1</Version>
<Version>2026.1.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
@@ -13,21 +13,21 @@
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup>
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
<MicrosoftNetTestSdkVersion>18.0.1</MicrosoftNetTestSdkVersion>
<XUnitVersion>2.6.6</XUnitVersion>
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
</Project>

View File

@@ -58,6 +58,42 @@ Invoke-RestMethod -OutFile bitwarden.ps1 `
.\bitwarden.ps1 -start
```
## Production Container Images
<details>
<summary><b>View Current Production Image Hashes</b> (click to expand)</summary>
<br>
### US Production Cluster
| Service | Image Hash |
|---------|------------|
| **Admin** | ![admin](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.admin&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **API** | ![api](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.api&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Billing** | ![billing](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.billing&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Events** | ![events](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.events&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **EventsProcessor** | ![eventsprocessor](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.eventsprocessor&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Identity** | ![identity](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.identity&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Notifications** | ![notifications](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.notifications&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SCIM** | ![scim](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.scim&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SSO** | ![sso](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-us.json&query=%24.sso&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
### EU Production Cluster
| Service | Image Hash |
|---------|------------|
| **Admin** | ![admin](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.admin&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **API** | ![api](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.api&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Billing** | ![billing](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.billing&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Events** | ![events](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.events&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **EventsProcessor** | ![eventsprocessor](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.eventsprocessor&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Identity** | ![identity](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.identity&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **Notifications** | ![notifications](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.notifications&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SCIM** | ![scim](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.scim&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
| **SSO** | ![sso](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fbitwarden%2Fserver%2Frefs%2Fheads%2Fmetadata%2Fbadges%2Fshieldsio-badge-eu.json&query=%24.sso&style=flat-square&logo=docker&logoColor=white&label=&color=2496ED) |
</details>
## We're Hiring!
Interested in contributing in a big way? Consider joining our team! We're hiring for many positions. Please take a look at our [Careers page](https://bitwarden.com/careers/) to see what opportunities are currently open as well as what it's like to work at Bitwarden.

View File

@@ -1,7 +1,7 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29102.190
# Visual Studio Version 17
VisualStudioVersion = 17.14.36705.20 d17.14
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src - AGPL", "src - AGPL", "{DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}"
EndProject
@@ -11,19 +11,19 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DD5BD056-4
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{458155D3-BCBC-481D-B37A-40D2ED10F0A4}"
ProjectSection(SolutionItems) = preProject
.dockerignore = .dockerignore
.editorconfig = .editorconfig
.gitignore = .gitignore
CONTRIBUTING.md = CONTRIBUTING.md
Directory.Build.props = Directory.Build.props
global.json = global.json
.gitignore = .gitignore
README.md = README.md
.editorconfig = .editorconfig
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
SECURITY.md = SECURITY.md
LICENSE_FAQ.md = LICENSE_FAQ.md
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
LICENSE_AGPL.txt = LICENSE_AGPL.txt
LICENSE.txt = LICENSE.txt
CONTRIBUTING.md = CONTRIBUTING.md
.dockerignore = .dockerignore
LICENSE_AGPL.txt = LICENSE_AGPL.txt
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
LICENSE_FAQ.md = LICENSE_FAQ.md
README.md = README.md
SECURITY.md = SECURITY.md
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Core", "src\Core\Core.csproj", "{3973D21B-A692-4B60-9B70-3631C057423A}"
@@ -134,10 +134,19 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sso.IntegrationTest", "bitwarden_license\test\Sso.IntegrationTest\Sso.IntegrationTest.csproj", "{FFB09376-595B-6F93-36F0-70CAE90AFECB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Server.IntegrationTest", "test\Server.IntegrationTest\Server.IntegrationTest.csproj", "{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -350,10 +359,26 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}.Release|Any CPU.Build.0 = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FFB09376-595B-6F93-36F0-70CAE90AFECB}.Release|Any CPU.Build.0 = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -410,7 +435,11 @@ Global
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{FFB09376-595B-6F93-36F0-70CAE90AFECB} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{E75E1F10-BC6F-4EB1-BA75-D897C45AEA0D} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -113,7 +113,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
}
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
var customer = await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,
Email = organization.BillingEmail,
@@ -138,7 +138,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
var subscription = await _stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created;
@@ -148,27 +148,26 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
}
else if (organization.IsStripeEnabled())
{
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
var subscription = await _stripeAdapter.GetSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
{
Expand = ["customer"]
});
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{
return;
}
await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
await _stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions
{
Email = organization.BillingEmail
});
if (subscription.Customer.Discount?.Coupon != null)
{
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
await _stripeAdapter.DeleteCustomerDiscountAsync(subscription.CustomerId);
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
await _stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30,

View File

@@ -9,12 +9,16 @@ using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -59,6 +63,7 @@ public class ProviderService : IProviderService
private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand;
private readonly IPolicyRequirementQuery _policyRequirementQuery;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@@ -68,7 +73,8 @@ public class ProviderService : IProviderService
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient,
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand)
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand,
IPolicyRequirementQuery policyRequirementQuery)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
@@ -89,6 +95,7 @@ public class ProviderService : IProviderService
_providerBillingService = providerBillingService;
_pricingClient = pricingClient;
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
_policyRequirementQuery = policyRequirementQuery;
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
@@ -116,6 +123,18 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(ownerUserId);
if (organizationAutoConfirmPolicyRequirement
.CannotCreateProvider())
{
throw new BadRequestException(new UserCannotJoinProvider().Message);
}
}
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
@@ -248,6 +267,18 @@ public class ProviderService : IProviderService
throw new BadRequestException("User email does not match invite.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
if (organizationAutoConfirmPolicyRequirement
.CannotJoinProvider())
{
throw new BadRequestException(new UserCannotJoinProvider().Message);
}
}
providerUser.Status = ProviderUserStatusType.Accepted;
providerUser.UserId = user.Id;
providerUser.Email = null;
@@ -293,6 +324,19 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid user.");
}
if (_featureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
var organizationAutoConfirmPolicyRequirement = await _policyRequirementQuery
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
if (organizationAutoConfirmPolicyRequirement
.CannotJoinProvider())
{
result.Add(Tuple.Create(providerUser, new UserCannotJoinProvider().Message));
continue;
}
}
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Key = keys[providerUser.Id];
providerUser.Email = null;
@@ -427,7 +471,7 @@ public class ProviderService : IProviderService
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
await _stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Email = provider.BillingEmail
});
@@ -487,7 +531,7 @@ public class ProviderService : IProviderService
private async Task<SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
{
var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId);
var subscriptionDetails = await _stripeAdapter.GetSubscriptionAsync(subscriptionId);
return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId);
}
@@ -497,7 +541,7 @@ public class ProviderService : IProviderService
{
if (subscriptionItem.Price.Id != extractedPlanType)
{
await _stripeAdapter.SubscriptionUpdateAsync(subscriptionItem.Subscription,
await _stripeAdapter.UpdateSubscriptionAsync(subscriptionItem.Subscription,
new Stripe.SubscriptionUpdateOptions
{
Items = new List<Stripe.SubscriptionItemOptions>

View File

@@ -4,7 +4,6 @@ using Bit.Core.Billing.Providers.Models;
using Bit.Core.Billing.Providers.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Stripe;
using Stripe.Tax;
@@ -76,8 +75,8 @@ public class GetProviderWarningsQuery(
// Get active and scheduled registrations
var registrations = (await Task.WhenAll(
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
stripeAdapter.ListTaxRegistrationsAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
.SelectMany(registrations => registrations.Data);
// Find the matching registration for the customer

View File

@@ -101,7 +101,7 @@ public class BusinessUnitConverter(
providerUser.Status = ProviderUserStatusType.Confirmed;
// Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them.
await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
await stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
@@ -116,7 +116,7 @@ public class BusinessUnitConverter(
["convertedFrom"] = organization.Id.ToString()
};
var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
var updateCustomer = stripeAdapter.UpdateCustomerAsync(subscription.CustomerId, new CustomerUpdateOptions
{
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
@@ -148,7 +148,7 @@ public class BusinessUnitConverter(
// Replace the existing password manager price with the new business unit price.
var updateSubscription =
stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
stripeAdapter.UpdateSubscriptionAsync(subscription.Id,
new SubscriptionUpdateOptions
{
Items = [

View File

@@ -61,11 +61,11 @@ public class ProviderBillingService(
Organization organization,
string key)
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
await stripeAdapter.UpdateSubscriptionAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CancelAtPeriodEnd = false });
var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
await stripeAdapter.CancelSubscriptionAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
@@ -83,7 +83,7 @@ public class ProviderBillingService(
if (!wasTrialing && subscription.LatestInvoice.Status == InvoiceStatus.Draft)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
await stripeAdapter.FinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
}
@@ -138,7 +138,7 @@ public class ProviderBillingService(
if (clientCustomer.Balance != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
await stripeAdapter.CreateCustomerBalanceTransactionAsync(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = clientCustomer.Balance,
@@ -187,7 +187,7 @@ public class ProviderBillingService(
]
};
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, updateOptions);
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
@@ -275,7 +275,7 @@ public class ProviderBillingService(
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
}
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
var customer = await stripeAdapter.CreateCustomerAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id;
@@ -525,7 +525,7 @@ public class ProviderBillingService(
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions
(await stripeAdapter.ListSetupIntentsAsync(new SetupIntentListOptions
{
PaymentMethod = paymentMethod.Token
}))
@@ -558,7 +558,7 @@ public class ProviderBillingService(
try
{
return await stripeAdapter.CustomerCreateAsync(options);
return await stripeAdapter.CreateCustomerAsync(options);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
{
@@ -580,7 +580,7 @@ public class ProviderBillingService(
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
await stripeAdapter.SetupIntentCancel(setupIntentId,
await stripeAdapter.CancelSetupIntentAsync(setupIntentId,
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
break;
@@ -638,7 +638,7 @@ public class ProviderBillingService(
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
? await stripeAdapter.SetupIntentGet(setupIntentId,
? await stripeAdapter.GetSetupIntentAsync(setupIntentId,
new SetupIntentGetOptions { Expand = ["payment_method"] })
: null;
@@ -673,7 +673,7 @@ public class ProviderBillingService(
try
{
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
var subscription = await stripeAdapter.CreateSubscriptionAsync(subscriptionCreateOptions);
if (subscription is
{
@@ -708,7 +708,7 @@ public class ProviderBillingService(
subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource),
subscriberService.UpdateTaxInformation(provider, taxInformation));
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
}
@@ -791,11 +791,49 @@ public class ProviderBillingService(
if (subscriptionItemOptionsList.Count > 0)
{
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
}
}
public async Task UpdateProviderNameAndEmail(Provider provider)
{
if (string.IsNullOrWhiteSpace(provider.GatewayCustomerId))
{
logger.LogWarning(
"Provider ({ProviderId}) has no Stripe customer to update",
provider.Id);
return;
}
var newDisplayName = provider.DisplayName();
// Provider.DisplayName() can return null - handle gracefully
if (string.IsNullOrWhiteSpace(newDisplayName))
{
logger.LogWarning(
"Provider ({ProviderId}) has no name to update in Stripe",
provider.Id);
return;
}
await stripeAdapter.UpdateCustomerAsync(provider.GatewayCustomerId,
new CustomerUpdateOptions
{
Email = provider.BillingEmail,
Description = newDisplayName,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields = [
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = newDisplayName
}]
},
});
}
private Func<int, Task> CurrySeatScalingUpdate(
Provider provider,
ProviderPlan providerPlan,
@@ -807,7 +845,7 @@ public class ProviderBillingService(
var item = subscription.Items.First(item => item.Price.Id == priceId);
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
await stripeAdapter.UpdateSubscriptionAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
Items =
[

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,21 +11,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
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()
.Run();
}

View File

@@ -44,6 +44,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();
@@ -94,11 +95,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.E
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
using Bit.Core.AdminConsole.Utilities.Commands;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
@@ -24,7 +25,7 @@ public class PostUserCommand(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IPaymentService paymentService,
IStripePaymentService paymentService,
IScimContext scimContext,
IFeatureService featureService,
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,

View File

@@ -23,11 +23,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

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

View File

@@ -201,12 +201,15 @@ public class AccountController : Controller
returnUrl,
state = context.Parameters["state"],
userIdentifier = context.Parameters["session_state"],
ssoToken
});
}
[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))
{
returnUrl = "~/";
@@ -235,6 +238,31 @@ public class AccountController : Controller
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]
public async Task<IActionResult> ExternalCallback()
{
@@ -434,6 +462,7 @@ public class AccountController : Controller
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
var provider = result.Properties.Items["scheme"];
//Todo: Validate provider is a valid GUID with TryParse instead. When this is invalid it throws an exception
var orgId = new Guid(provider);
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
if (ssoConfig == null || !ssoConfig.Enabled)
@@ -587,7 +616,7 @@ public class AccountController : Controller
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// We've verified that the user is Accepted or Confirmed, so we can create an SsoUser link and proceed
// with authentication.
await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
@@ -652,22 +681,10 @@ public class AccountController : Controller
ApiKey = CoreHelpers.SecureRandomString(30)
};
/*
The feature flag is checked here so that we can send the new MJML welcome email templates.
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
TODO: Remove Feature flag: PM-28221
*/
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
{
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
}
else
{
await _registerUserCommand.RegisterUser(newUser);
}
// Always use RegisterSSOAutoProvisionedUserAsync to ensure organization context is available
// for domain validation (BlockClaimedDomainAccountCreation policy) and welcome emails.
// The feature flag logic for welcome email templates is handled internally by RegisterUserCommand.
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =

View File

@@ -0,0 +1,102 @@
using Bit.Sso.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.Sso.IdentityServer;
/// <summary>
/// Distributed cache-backed persisted grant store for short-lived grants.
/// Uses IFusionCache (which wraps IDistributedCache) for horizontal scaling support,
/// and fall back to in-memory caching if Redis is not configured.
/// Designed for SSO authorization codes which are short-lived (5 minutes) and single-use.
/// </summary>
/// <remarks>
/// This is purposefully a different implementation from how Identity solves Persisted Grants.
/// Because even flavored grant store, e.g., AuthorizationCodeGrantStore, can add intermediary
/// logic to a grant's handling by type, the fact that they all wrap IdentityServer's IPersistedGrantStore
/// leans on IdentityServer's opinion that all grants, regardless of type, go to the same persistence
/// mechanism (cache, database).
/// <seealso href="https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/"/>
/// </remarks>
public class DistributedCachePersistedGrantStore : IPersistedGrantStore
{
private readonly IFusionCache _cache;
public DistributedCachePersistedGrantStore(
[FromKeyedServices(PersistedGrantsDistributedCacheConstants.CacheKey)] IFusionCache cache)
{
_cache = cache;
}
public async Task<PersistedGrant?> GetAsync(string key)
{
var result = await _cache.TryGetAsync<PersistedGrant>(key);
if (!result.HasValue)
{
return null;
}
var grant = result.Value;
// Check if grant has expired - remove expired grants from cache
if (grant.Expiration.HasValue && grant.Expiration.Value < DateTime.UtcNow)
{
await RemoveAsync(key);
return null;
}
return grant;
}
public Task<IEnumerable<PersistedGrant>> GetAllAsync(PersistedGrantFilter filter)
{
// Cache stores are key-value based and don't support querying by filter criteria.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(Enumerable.Empty<PersistedGrant>());
}
public Task RemoveAllAsync(PersistedGrantFilter filter)
{
// Revocation Strategy: SSO's logout flow (AccountController.LogoutAsync) only clears local
// authentication cookies and performs federated logout with external IdPs. It does not invoke
// Duende's EndSession or TokenRevocation endpoints. Authorization codes are single-use and expire
// within 5 minutes, making explicit revocation unnecessary for SSO's security model.
// https://docs.duendesoftware.com/identityserver/reference/stores/persisted-grant-store/
// Cache stores are key-value based and don't support bulk deletion by filter.
// This method is typically used for cleanup operations on long-lived grants in databases.
// For SSO's short-lived authorization codes, we rely on TTL expiration instead.
return Task.FromResult(0);
}
public async Task RemoveAsync(string key)
{
await _cache.RemoveAsync(key);
}
public async Task StoreAsync(PersistedGrant grant)
{
// Calculate TTL based on grant expiration
var duration = grant.Expiration.HasValue
? grant.Expiration.Value - DateTime.UtcNow
: TimeSpan.FromMinutes(5); // Default to 5 minutes if no expiration set
// Ensure positive duration
if (duration <= TimeSpan.Zero)
{
return;
}
// Cache key "sso-grants:" is configured by service registration. Going through the consumed KeyedService will
// give us a consistent cache key prefix for these grants.
await _cache.SetAsync(
grant.Key,
grant,
new FusionCacheEntryOptions { Duration = duration });
}
}

View File

@@ -1,5 +1,4 @@
using Bit.Core.Utilities;
using Serilog;
namespace Bit.Sso;
@@ -13,19 +12,8 @@ public class Program
.ConfigureWebHostDefaults(webBuilder =>
{
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()
.Run();
}

View File

@@ -41,6 +41,7 @@ public class Startup
// Repositories
services.AddDatabaseRepositories(globalSettings);
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();
@@ -100,8 +101,6 @@ public class Startup
IdentityModelEventSource.ShowPII = true;
}
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -0,0 +1,10 @@
namespace Bit.Sso.Utilities;
public static class PersistedGrantsDistributedCacheConstants
{
/// <summary>
/// The SSO Persisted Grant cache key. Identifies the keyed service consumed by the SSO Persisted Grant Store as
/// well as the cache key/namespace for grant storage.
/// </summary>
public const string CacheKey = "sso-grants";
}

View File

@@ -9,6 +9,7 @@ using Bit.Sso.IdentityServer;
using Bit.Sso.Models;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.ResponseHandling;
using Duende.IdentityServer.Stores;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Sustainsys.Saml2.AspNetCore2;
@@ -77,6 +78,17 @@ public static class ServiceCollectionExtensions
})
.AddIdentityServerCertificate(env, globalSettings);
// PM-23572
// Register named FusionCache for SSO authorization code grants.
// Provides separation of concerns and automatic Redis/in-memory negotiation
// .AddInMemoryCaching should still persist above; this handles configuration caching, etc.,
// and is separate from this keyed service, which only serves grant negotiation.
services.AddExtendedCache(PersistedGrantsDistributedCacheConstants.CacheKey, globalSettings);
// Store authorization codes in distributed cache for horizontal scaling
// Uses named FusionCache which gracefully degrades to in-memory when Redis isn't configured
services.AddSingleton<IPersistedGrantStore, DistributedCachePersistedGrantStore>();
return identityServerBuilder;
}
}

View File

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

View File

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

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -749,9 +749,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -792,9 +792,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -813,11 +813,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -834,9 +834,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -988,9 +988,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1022,9 +1022,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1418,13 +1418,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1541,9 +1545,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1874,9 +1878,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2109,9 +2113,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2165,9 +2169,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2217,9 +2221,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2232,21 +2236,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -131,7 +131,7 @@ public class RemoveOrganizationFromProviderCommandTests
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
.UpdateCustomerAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
@@ -156,7 +156,7 @@ public class RemoveOrganizationFromProviderCommandTests
"b@example.com"
]);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
sutProvider.GetDependency<IStripeAdapter>().GetSubscriptionAsync(organization.GatewaySubscriptionId, Arg.Is<SubscriptionGetOptions>(
options => options.Expand.Contains("customer")))
.Returns(GetSubscription(organization.GatewaySubscriptionId, organization.GatewayCustomerId));
@@ -164,12 +164,14 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
await stripeAdapter.Received(1).UpdateCustomerAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerDeleteDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
await stripeAdapter.Received(1).DeleteCustomerDiscountAsync(organization.GatewayCustomerId);
await stripeAdapter.Received(1).UpdateSubscriptionAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30));
@@ -226,7 +228,7 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
@@ -239,14 +241,14 @@ public class RemoveOrganizationFromProviderCommandTests
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
@@ -315,7 +317,7 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(options =>
options.Description == string.Empty &&
options.Email == organization.BillingEmail &&
options.Expand[0] == "tax" &&
@@ -328,14 +330,14 @@ public class RemoveOrganizationFromProviderCommandTests
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "subscription_id"
});
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
await stripeAdapter.Received(1).CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
@@ -434,7 +436,7 @@ public class RemoveOrganizationFromProviderCommandTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
stripeAdapter.UpdateCustomerAsync(organization.GatewayCustomerId, Arg.Any<CustomerUpdateOptions>())
.Returns(new Customer
{
Id = "customer_id",
@@ -444,7 +446,7 @@ public class RemoveOrganizationFromProviderCommandTests
}
});
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
stripeAdapter.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "new_subscription_id"
});

View File

@@ -1,17 +1,23 @@
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Test.AdminConsole.AutoFixture;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.AutoConfirmUser;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@@ -100,6 +106,57 @@ public class ProviderServiceTests
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_WithAutoConfirmEnabled_ThrowsUserCannotJoinProviderError(User user, Provider provider,
string key,
TokenizedPaymentMethod tokenizedPaymentMethod, BillingAddress billingAddress,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
var userService = sutProvider.GetDependency<IUserService>();
userService.GetUserByIdAsync(user.Id).Returns(user);
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id).Returns(providerUser);
var dataProtectionProvider = DataProtectionProvider.Create("ApplicationName");
var protector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>().CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
var providerBillingService = sutProvider.GetDependency<IProviderBillingService>();
var customer = new Customer { Id = "customer_id" };
providerBillingService.SetupCustomer(provider, tokenizedPaymentMethod, billingAddress).Returns(customer);
var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyDetails = new List<PolicyDetails> { new() { OrganizationId = Guid.NewGuid(), IsProvider = false } };
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
.Returns(policyRequirement);
sutProvider.Create();
var token = protector.Protect(
$"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, tokenizedPaymentMethod,
billingAddress));
Assert.Equal(new UserCannotJoinProvider().Message, exception.Message);
}
[Theory, BitAutoData]
public async Task UpdateAsync_ProviderIdIsInvalid_Throws(Provider provider, SutProvider<ProviderService> sutProvider)
{
@@ -579,6 +636,132 @@ public class ProviderServiceTests
Assert.Equal(user.Id, pu.UserId);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_WithAutoConfirmEnabledAndPolicyExists_Throws(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
User user,
SutProvider<ProviderService> sutProvider)
{
// Arrange
sutProvider.GetDependency<IProviderUserRepository>()
.GetByIdAsync(providerUser.Id)
.Returns(providerUser);
var protector = DataProtectionProvider
.Create("ApplicationName")
.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>()
.CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
providerUser.Email = user.Email;
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyDetails = new List<PolicyDetails>
{
new() { OrganizationId = Guid.NewGuid(), IsProvider = false }
};
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
.Returns(policyRequirement);
// Act & Assert
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token));
Assert.Equal(new UserCannotJoinProvider().Message, exception.Message);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_WithAutoConfirmEnabledButNoPolicyExists_Success(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
User user,
SutProvider<ProviderService> sutProvider)
{
// Arrange
sutProvider.GetDependency<IProviderUserRepository>()
.GetByIdAsync(providerUser.Id)
.Returns(providerUser);
var protector = DataProtectionProvider
.Create("ApplicationName")
.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>()
.CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
providerUser.Email = user.Email;
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement([]);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id)
.Returns(policyRequirement);
// Act
var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token);
// Assert
Assert.Null(pu.Email);
Assert.Equal(ProviderUserStatusType.Accepted, pu.Status);
Assert.Equal(user.Id, pu.UserId);
}
[Theory, BitAutoData]
public async Task AcceptUserAsync_WithAutoConfirmDisabled_Success(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser providerUser,
User user,
SutProvider<ProviderService> sutProvider)
{
// Arrange
sutProvider.GetDependency<IProviderUserRepository>()
.GetByIdAsync(providerUser.Id)
.Returns(providerUser);
var protector = DataProtectionProvider
.Create("ApplicationName")
.CreateProtector("ProviderServiceDataProtector");
sutProvider.GetDependency<IDataProtectionProvider>()
.CreateProtector("ProviderServiceDataProtector")
.Returns(protector);
sutProvider.Create();
providerUser.Email = user.Email;
var token = protector.Protect($"ProviderUserInvite {providerUser.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
// Act
var pu = await sutProvider.Sut.AcceptUserAsync(providerUser.Id, user, token);
// Assert
Assert.Null(pu.Email);
Assert.Equal(ProviderUserStatusType.Accepted, pu.Status);
Assert.Equal(user.Id, pu.UserId);
// Verify that policy check was never called when feature flag is disabled
await sutProvider.GetDependency<IPolicyRequirementQuery>()
.DidNotReceive()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(user.Id);
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_NoValid(
[ProviderUser(ProviderUserStatusType.Invited)] ProviderUser pu1,
@@ -625,13 +808,131 @@ public class ProviderServiceTests
Assert.Equal("Invalid user.", result[2].Item2);
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_WithAutoConfirmEnabledAndPolicyExists_ReturnsError(
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
{
// Arrange
pu1.ProviderId = provider.Id;
pu1.UserId = u1.Id;
var providerUsers = new[] { pu1 };
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyDetails = new List<PolicyDetails>
{
new() { OrganizationId = Guid.NewGuid(), IsProvider = false }
};
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(policyDetails);
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(u1.Id)
.Returns(policyRequirement);
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
// Act
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
// Assert
Assert.Single(result);
Assert.Equal(new UserCannotJoinProvider().Message, result[0].Item2);
// Verify user was not confirmed
await providerUserRepository.DidNotReceive().ReplaceAsync(Arg.Any<ProviderUser>());
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_WithAutoConfirmEnabledButNoPolicyExists_Success(
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
{
// Arrange
pu1.ProviderId = provider.Id;
pu1.UserId = u1.Id;
var providerUsers = new[] { pu1 };
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(true);
var policyRequirement = new AutomaticUserConfirmationPolicyRequirement(new List<PolicyDetails>());
sutProvider.GetDependency<IPolicyRequirementQuery>()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(u1.Id)
.Returns(policyRequirement);
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
// Act
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
// Assert
Assert.Single(result);
Assert.Equal("", result[0].Item2);
// Verify user was confirmed
await providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(pu =>
pu.Status == ProviderUserStatusType.Confirmed));
}
[Theory, BitAutoData]
public async Task ConfirmUsersAsync_WithAutoConfirmDisabled_Success(
[ProviderUser(ProviderUserStatusType.Accepted)] ProviderUser pu1, User u1,
Provider provider, User confirmingUser, SutProvider<ProviderService> sutProvider)
{
// Arrange
pu1.ProviderId = provider.Id;
pu1.UserId = u1.Id;
var providerUsers = new[] { pu1 };
var providerUserRepository = sutProvider.GetDependency<IProviderUserRepository>();
providerUserRepository.GetManyAsync([]).ReturnsForAnyArgs(providerUsers);
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IUserRepository>().GetManyAsync([]).ReturnsForAnyArgs([u1]);
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)
.Returns(false);
var dict = providerUsers.ToDictionary(pu => pu.Id, _ => "key");
// Act
var result = await sutProvider.Sut.ConfirmUsersAsync(pu1.ProviderId, dict, confirmingUser.Id);
// Assert
Assert.Single(result);
Assert.Equal("", result[0].Item2);
// Verify user was confirmed
await providerUserRepository.Received(1).ReplaceAsync(Arg.Is<ProviderUser>(pu =>
pu.Status == ProviderUserStatusType.Confirmed));
// Verify that policy check was never called when feature flag is disabled
await sutProvider.GetDependency<IPolicyRequirementQuery>()
.DidNotReceive()
.GetAsync<AutomaticUserConfirmationPolicyRequirement>(Arg.Any<Guid>());
}
[Theory, BitAutoData]
public async Task SaveUserAsync_UserIdIsInvalid_Throws(ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.Id = default;
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.SaveUserAsync(providerUser, default));
providerUser.Id = Guid.Empty;
var exception = await Assert.ThrowsAsync<BadRequestException>(() =>
sutProvider.Sut.SaveUserAsync(providerUser, Guid.Empty));
Assert.Equal("Invite the user first.", exception.Message);
}
@@ -757,7 +1058,7 @@ public class ProviderServiceTests
await organizationRepository.Received(1)
.ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == provider.BillingEmail));
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateCustomerAsync(
organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options => options.Email == provider.BillingEmail));
@@ -828,9 +1129,9 @@ public class ProviderServiceTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
var subscriptionItem = GetSubscription(organization.GatewaySubscriptionId);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
sutProvider.GetDependency<IStripeAdapter>().GetSubscriptionAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.GetDependency<IStripeAdapter>().SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().UpdateSubscriptionAsync(
organization.GatewaySubscriptionId, SubscriptionUpdateRequest(expectedPlanId, subscriptionItem));
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);

View File

@@ -3,7 +3,6 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
@@ -63,7 +62,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -95,7 +94,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -129,7 +128,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(false);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -163,7 +162,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -224,7 +223,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "GB" }]
@@ -257,7 +256,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -296,7 +295,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -338,7 +337,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -383,7 +382,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -428,7 +427,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -461,7 +460,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Active))
.Returns(new StripeList<Registration>
{
Data = [
@@ -470,7 +469,7 @@ public class GetProviderWarningsQueryTests
new Registration { Country = "FR" }
]
});
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Is<RegistrationListOptions>(opt => opt.Status == TaxRegistrationStatus.Scheduled))
.Returns(new StripeList<Registration> { Data = [] });
var response = await sutProvider.Sut.Run(provider);
@@ -505,7 +504,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "CA" }]
@@ -543,7 +542,7 @@ public class GetProviderWarningsQueryTests
});
sutProvider.GetDependency<ICurrentContext>().ProviderProviderAdmin(provider.Id).Returns(true);
sutProvider.GetDependency<IStripeAdapter>().TaxRegistrationsListAsync(Arg.Any<RegistrationListOptions>())
sutProvider.GetDependency<IStripeAdapter>().ListTaxRegistrationsAsync(Arg.Any<RegistrationListOptions>())
.Returns(new StripeList<Registration>
{
Data = [new Registration { Country = "US" }]

View File

@@ -144,11 +144,11 @@ public class BusinessUnitConverterTests
await businessUnitConverter.FinalizeConversion(organization, userId, token, providerKey, organizationKey);
await _stripeAdapter.Received(2).CustomerUpdateAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
await _stripeAdapter.Received(2).UpdateCustomerAsync(subscription.CustomerId, Arg.Any<CustomerUpdateOptions>());
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, enterpriseAnnually.Type);
await _stripeAdapter.Received(1).SubscriptionUpdateAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
await _stripeAdapter.Received(1).UpdateSubscriptionAsync(subscription.Id, Arg.Is<SubscriptionUpdateOptions>(
arguments =>
arguments.Items.Count == 2 &&
arguments.Items[0].Id == "subscription_item_id" &&

View File

@@ -20,7 +20,6 @@ using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Test.Billing.Mocks;
using Bit.Test.Common.AutoFixture;
@@ -85,7 +84,7 @@ public class ProviderBillingServiceTests
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
@@ -113,7 +112,7 @@ public class ProviderBillingServiceTests
// Assert
await providerPlanRepository.Received(0).ReplaceAsync(Arg.Any<ProviderPlan>());
await stripeAdapter.Received(0).SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await stripeAdapter.Received(0).UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
@@ -180,14 +179,14 @@ public class ProviderBillingServiceTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
.UpdateSubscriptionAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si => si.Id == "si_ent_annual" && si.Deleted == true) == 1));
var newPlanCfg = MockPlans.Get(command.NewPlan);
await stripeAdapter.Received(1)
.SubscriptionUpdateAsync(
.UpdateSubscriptionAsync(
Arg.Is(provider.GatewaySubscriptionId),
Arg.Is<SubscriptionUpdateOptions>(p =>
p.Items.Count(si =>
@@ -268,7 +267,7 @@ public class ProviderBillingServiceTests
CloudRegion = "US"
});
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -288,7 +287,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -349,7 +348,7 @@ public class ProviderBillingServiceTests
CloudRegion = "US"
});
sutProvider.GetDependency<IStripeAdapter>().CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -370,7 +369,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.CreateCustomerForClientOrganization(provider, organization);
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(
options =>
options.Address.Country == providerCustomer.Address.Country &&
options.Address.PostalCode == providerCustomer.Address.PostalCode &&
@@ -535,7 +534,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 50 assigned seats + 10 seat scale up = 60 seats, well below the 100 minimum
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs().UpdateSubscriptionAsync(
Arg.Any<string>(),
Arg.Any<SubscriptionUpdateOptions>());
@@ -619,7 +618,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 95 current + 10 seat scale = 105 seats, 5 above the minimum
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateSubscriptionAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
@@ -707,7 +706,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, 10);
// 110 current + 10 seat scale up = 120 seats
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateSubscriptionAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
@@ -795,7 +794,7 @@ public class ProviderBillingServiceTests
await sutProvider.Sut.ScaleSeats(provider, PlanType.TeamsMonthly, -30);
// 110 seats - 30 scale down seats = 80 seats, below the 100 seat minimum.
await sutProvider.GetDependency<IStripeAdapter>().Received(1).SubscriptionUpdateAsync(
await sutProvider.GetDependency<IStripeAdapter>().Received(1).UpdateSubscriptionAsync(
provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
@@ -914,12 +913,12 @@ public class ProviderBillingServiceTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -942,7 +941,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).Set(provider.Id, "setup_intent_id");
await stripeAdapter.Received(1).SetupIntentCancel("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
await stripeAdapter.Received(1).CancelSetupIntentAsync("setup_intent_id", Arg.Is<SetupIntentCancelOptions>(options =>
options.CancellationReason == "abandoned"));
await sutProvider.GetDependency<ISetupIntentCache>().Received(1).RemoveSetupIntentForSubscriber(provider.Id);
@@ -964,7 +963,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1007,12 +1006,12 @@ public class ProviderBillingServiceTests
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.BankAccount, Token = "token" };
stripeAdapter.SetupIntentList(Arg.Is<SetupIntentListOptions>(options =>
stripeAdapter.ListSetupIntentsAsync(Arg.Is<SetupIntentListOptions>(options =>
options.PaymentMethod == tokenizedPaymentMethod.Token)).Returns([
new SetupIntent { Id = "setup_intent_id" }
]);
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1058,7 +1057,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISubscriberService>().CreateBraintreeCustomer(provider, tokenizedPaymentMethod.Token)
.Returns("braintree_customer_id");
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1100,7 +1099,7 @@ public class ProviderBillingServiceTests
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1142,7 +1141,7 @@ public class ProviderBillingServiceTests
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Is<CustomerCreateOptions>(o =>
stripeAdapter.CreateCustomerAsync(Arg.Is<CustomerCreateOptions>(o =>
o.Address.Country == billingAddress.Country &&
o.Address.PostalCode == billingAddress.PostalCode &&
o.Address.Line1 == billingAddress.Line1 &&
@@ -1178,7 +1177,7 @@ public class ProviderBillingServiceTests
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
var tokenizedPaymentMethod = new TokenizedPaymentMethod { Type = TokenizablePaymentMethodType.Card, Token = "token" };
stripeAdapter.CustomerCreateAsync(Arg.Any<CustomerCreateOptions>())
stripeAdapter.CreateCustomerAsync(Arg.Any<CustomerCreateOptions>())
.Throws(new StripeException("Invalid tax ID") { StripeError = new StripeError { Code = "tax_id_invalid" } });
var actual = await Assert.ThrowsAsync<BadRequestException>(async () =>
@@ -1216,7 +1215,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
@@ -1244,7 +1243,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
@@ -1272,7 +1271,7 @@ public class ProviderBillingServiceTests
await sutProvider.GetDependency<IStripeAdapter>()
.DidNotReceiveWithAnyArgs()
.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>());
.CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>());
}
[Theory, BitAutoData]
@@ -1323,7 +1322,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<IProviderPlanRepository>().GetByProviderId(provider.Id)
.Returns(providerPlans);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>())
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Any<SubscriptionCreateOptions>())
.Returns(
new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Incomplete });
@@ -1381,7 +1380,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
@@ -1458,7 +1457,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1538,7 +1537,7 @@ public class ProviderBillingServiceTests
sutProvider.GetDependency<ISetupIntentCache>().GetSetupIntentIdForSubscriber(provider.Id).Returns(setupIntentId);
sutProvider.GetDependency<IStripeAdapter>().SetupIntentGet(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
sutProvider.GetDependency<IStripeAdapter>().GetSetupIntentAsync(setupIntentId, Arg.Is<SetupIntentGetOptions>(options =>
options.Expand.Contains("payment_method"))).Returns(new SetupIntent
{
Id = setupIntentId,
@@ -1553,7 +1552,7 @@ public class ProviderBillingServiceTests
}
});
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1635,7 +1634,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1713,7 +1712,7 @@ public class ProviderBillingServiceTests
var expected = new Subscription { Id = "subscription_id", Status = StripeConstants.SubscriptionStatus.Active };
sutProvider.GetDependency<IStripeAdapter>().SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(
sutProvider.GetDependency<IStripeAdapter>().CreateSubscriptionAsync(Arg.Is<SubscriptionCreateOptions>(
sub =>
sub.AutomaticTax.Enabled == true &&
sub.CollectionMethod == StripeConstants.CollectionMethod.ChargeAutomatically &&
@@ -1828,7 +1827,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 20 && providerPlan.PurchasedSeats == 5));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 2 &&
@@ -1908,7 +1907,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 50));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 2 &&
@@ -1989,7 +1988,7 @@ public class ProviderBillingServiceTests
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 60 && providerPlan.PurchasedSeats == 10));
await stripeAdapter.DidNotReceiveWithAnyArgs()
.SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
.UpdateSubscriptionAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
}
[Theory, BitAutoData]
@@ -2062,7 +2061,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.Received(1).ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly && providerPlan.SeatMinimum == 80 && providerPlan.PurchasedSeats == 0));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 2 &&
@@ -2142,7 +2141,7 @@ public class ProviderBillingServiceTests
await providerPlanRepository.DidNotReceive().ReplaceAsync(Arg.Is<ProviderPlan>(
providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
await stripeAdapter.Received(1).UpdateSubscriptionAsync(provider.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(
options =>
options.Items.Count == 1 &&
@@ -2151,4 +2150,151 @@ public class ProviderBillingServiceTests
}
#endregion
#region UpdateProviderNameAndEmail
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullGatewayCustomerId_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.GatewayCustomerId = null;
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_EmptyGatewayCustomerId_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.GatewayCustomerId = "";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullProviderName_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = null;
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_EmptyProviderName_LogsWarningAndReturns(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.DidNotReceive().UpdateCustomerAsync(
Arg.Any<string>(),
Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_ValidProvider_CallsStripeWithCorrectParameters(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "Test Provider";
provider.BillingEmail = "billing@test.com";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == provider.BillingEmail &&
options.Description == provider.Name &&
options.InvoiceSettings.CustomFields.Count == 1 &&
options.InvoiceSettings.CustomFields[0].Name == "Provider" &&
options.InvoiceSettings.CustomFields[0].Value == provider.Name));
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_LongProviderName_UsesFullName(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
var longName = new string('A', 50); // 50 characters
provider.Name = longName;
provider.BillingEmail = "billing@test.com";
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.InvoiceSettings.CustomFields[0].Value == longName));
}
[Theory, BitAutoData]
public async Task UpdateProviderNameAndEmail_NullBillingEmail_UpdatesWithNull(
Provider provider,
SutProvider<ProviderBillingService> sutProvider)
{
// Arrange
provider.Name = "Test Provider";
provider.BillingEmail = null;
provider.GatewayCustomerId = "cus_test123";
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
// Act
await sutProvider.Sut.UpdateProviderNameAndEmail(provider);
// Assert
await stripeAdapter.Received(1).UpdateCustomerAsync(
provider.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Email == null &&
options.Description == provider.Name));
}
#endregion
}

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,13 +3,14 @@ using System.Security.Claims;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Business.Tokenables;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.UserFeatures.Registration;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Bit.Sso.Controllers;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@@ -19,7 +20,6 @@ using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
@@ -1012,129 +1012,127 @@ public class AccountControllerTest
}
[Theory, BitAutoData]
public async Task AutoProvisionUserAsync_WithFeatureFlagEnabled_CallsRegisterSSOAutoProvisionedUser(
SutProvider<AccountController> sutProvider)
public void ExternalChallenge_WithMatchingOrgId_Succeeds(
SutProvider<AccountController> sutProvider,
Organization organization)
{
// Arrange
var orgId = Guid.NewGuid();
var providerUserId = "ext-new-user";
var email = "newuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
var orgId = organization.Id;
var scheme = orgId.ToString();
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "valid-sso-token";
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// 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);
// Feature flag enabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(true);
// Mock URL helper for IsLocalUrl check
var urlHelper = Substitute.For<IUrlHelper>();
urlHelper.IsLocalUrl(returnUrl).Returns(true);
sutProvider.Sut.Url = urlHelper;
// Mock the RegisterSSOAutoProvisionedUserAsync to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "New User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Mock interaction service for IsValidReturnUrl check
var interactionService = sutProvider.GetDependency<IIdentityServerInteractionService>();
interactionService.IsValidReturnUrl(returnUrl).Returns(true);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
var result = sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken);
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterSSOAutoProvisionedUserAsync(
Arg.Is<User>(u => u.Email == email && u.Name == "New User"),
Arg.Is<Organization>(o => o.Id == orgId && o.Name == "Test Org"));
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
Assert.Equal(organization.Id, result.organization.Id);
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 async Task AutoProvisionUserAsync_WithFeatureFlagDisabled_CallsRegisterUserInstead(
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 providerUserId = "ext-legacy-user";
var email = "legacyuser@example.com";
var organization = new Organization { Id = orgId, Name = "Test Org", Seats = null };
var scheme = orgId.ToString();
var returnUrl = "~/vault";
var state = "test-state";
var userIdentifier = "user-123";
var ssoToken = "invalid-corrupted-token";
// No existing user (JIT provisioning scenario)
sutProvider.GetDependency<IUserRepository>().GetByEmailAsync(email).Returns((User?)null);
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(orgId).Returns(organization);
sutProvider.GetDependency<IOrganizationUserRepository>().GetByOrganizationEmailAsync(orgId, email)
.Returns((OrganizationUser?)null);
// 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"));
// Feature flag disabled
sutProvider.GetDependency<IFeatureService>()
.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates)
.Returns(false);
// Mock i18n service to return the key
sutProvider.GetDependency<II18nService>()
.T(Arg.Any<string>())
.Returns(ci => (string)ci[0]!);
// Mock the RegisterUser to return success
sutProvider.GetDependency<IRegisterUserCommand>()
.RegisterUser(Arg.Any<User>())
.Returns(IdentityResult.Success);
var claims = new[]
{
new Claim(JwtClaimTypes.Email, email),
new Claim(JwtClaimTypes.Name, "Legacy User")
} as IEnumerable<Claim>;
var config = new SsoConfigurationData();
var method = typeof(AccountController).GetMethod(
"CreateUserAndOrgUserConditionallyAsync",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Act
var task = (Task<(User user, Organization organization, OrganizationUser orgUser)>)method!.Invoke(
sutProvider.Sut,
new object[]
{
orgId.ToString(),
providerUserId,
claims,
null!,
config
})!;
var result = await task;
// Assert
await sutProvider.GetDependency<IRegisterUserCommand>().Received(1)
.RegisterUser(Arg.Is<User>(u => u.Email == email && u.Name == "Legacy User"));
// Verify the new method was NOT called
await sutProvider.GetDependency<IRegisterUserCommand>().DidNotReceive()
.RegisterSSOAutoProvisionedUserAsync(Arg.Any<User>(), Arg.Any<Organization>());
Assert.NotNull(result.user);
Assert.Equal(email, result.user.Email);
// Act & Assert
var ex = Assert.Throws<Exception>(() =>
sutProvider.Sut.ExternalChallenge(scheme, returnUrl, state, userIdentifier, ssoToken));
Assert.Equal("InvalidSsoToken", ex.Message);
}
}

View File

@@ -0,0 +1,257 @@
using Bit.Sso.IdentityServer;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using NSubstitute;
using ZiggyCreatures.Caching.Fusion;
namespace Bit.SSO.Test.IdentityServer;
public class DistributedCachePersistedGrantStoreTests
{
private readonly IFusionCache _cache;
private readonly DistributedCachePersistedGrantStore _sut;
public DistributedCachePersistedGrantStoreTests()
{
_cache = Substitute.For<IFusionCache>();
_sut = new DistributedCachePersistedGrantStore(_cache);
}
[Fact]
public async Task StoreAsync_StoresGrantWithCalculatedTTL()
{
// Arrange
var grant = CreateTestGrant("test-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"test-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.Duration >= TimeSpan.FromMinutes(4.9) &&
opts.Duration <= TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithNoExpiration_UsesDefaultFiveMinuteTTL()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"no-expiry-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts => opts.Duration == TimeSpan.FromMinutes(5)));
}
[Fact]
public async Task StoreAsync_WithAlreadyExpiredGrant_DoesNotStore()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
// Act
await _sut.StoreAsync(expiredGrant);
// Assert
await _cache.DidNotReceive().SetAsync(
Arg.Any<string>(),
Arg.Any<PersistedGrant>(),
Arg.Any<FusionCacheEntryOptions?>());
}
[Fact]
public async Task StoreAsync_EnablesDistributedCache()
{
// Arrange
var grant = CreateTestGrant("distributed-key", expiration: DateTime.UtcNow.AddMinutes(5));
// Act
await _sut.StoreAsync(grant);
// Assert
await _cache.Received(1).SetAsync(
"distributed-key",
grant,
Arg.Is<FusionCacheEntryOptions>(opts =>
opts.SkipDistributedCache == false &&
opts.SkipDistributedCacheReadWhenStale == false));
}
[Fact]
public async Task GetAsync_WithValidGrant_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("valid-key", expiration: DateTime.UtcNow.AddMinutes(5));
_cache.TryGetAsync<PersistedGrant>("valid-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("valid-key");
// Assert
Assert.NotNull(result);
Assert.Equal("valid-key", result.Key);
Assert.Equal("authorization_code", result.Type);
Assert.Equal("test-subject", result.SubjectId);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithNonExistentKey_ReturnsNull()
{
// Arrange
_cache.TryGetAsync<PersistedGrant>("nonexistent-key")
.Returns(MaybeValue<PersistedGrant>.None);
// Act
var result = await _sut.GetAsync("nonexistent-key");
// Assert
Assert.Null(result);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task GetAsync_WithExpiredGrant_RemovesAndReturnsNull()
{
// Arrange
var expiredGrant = CreateTestGrant("expired-key", expiration: DateTime.UtcNow.AddMinutes(-1));
_cache.TryGetAsync<PersistedGrant>("expired-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(expiredGrant));
// Act
var result = await _sut.GetAsync("expired-key");
// Assert
Assert.Null(result);
await _cache.Received(1).RemoveAsync("expired-key");
}
[Fact]
public async Task GetAsync_WithNoExpiration_ReturnsGrant()
{
// Arrange
var grant = CreateTestGrant("no-expiry-key", expiration: null);
_cache.TryGetAsync<PersistedGrant>("no-expiry-key")
.Returns(MaybeValue<PersistedGrant>.FromValue(grant));
// Act
var result = await _sut.GetAsync("no-expiry-key");
// Assert
Assert.NotNull(result);
Assert.Equal("no-expiry-key", result.Key);
Assert.Null(result.Expiration);
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task RemoveAsync_RemovesGrantFromCache()
{
// Act
await _sut.RemoveAsync("remove-key");
// Assert
await _cache.Received(1).RemoveAsync("remove-key");
}
[Fact]
public async Task GetAllAsync_ReturnsEmptyCollection()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
SessionId = "test-session",
ClientId = "test-client",
Type = "authorization_code"
};
// Act
var result = await _sut.GetAllAsync(filter);
// Assert
Assert.NotNull(result);
Assert.Empty(result);
}
[Fact]
public async Task RemoveAllAsync_CompletesWithoutError()
{
// Arrange
var filter = new PersistedGrantFilter
{
SubjectId = "test-subject",
ClientId = "test-client"
};
// Act & Assert - should not throw
await _sut.RemoveAllAsync(filter);
// Verify no cache operations were performed
await _cache.DidNotReceive().RemoveAsync(Arg.Any<string>());
}
[Fact]
public async Task StoreAsync_PreservesAllGrantProperties()
{
// Arrange
var grant = new PersistedGrant
{
Key = "full-grant-key",
Type = "authorization_code",
SubjectId = "user-123",
SessionId = "session-456",
ClientId = "client-789",
Description = "Test grant",
CreationTime = DateTime.UtcNow.AddMinutes(-1),
Expiration = DateTime.UtcNow.AddMinutes(5),
ConsumedTime = null,
Data = "{\"test\":\"data\"}"
};
PersistedGrant? capturedGrant = null;
await _cache.SetAsync(
Arg.Any<string>(),
Arg.Do<PersistedGrant>(g => capturedGrant = g),
Arg.Any<FusionCacheEntryOptions?>());
// Act
await _sut.StoreAsync(grant);
// Assert
Assert.NotNull(capturedGrant);
Assert.Equal(grant.Key, capturedGrant.Key);
Assert.Equal(grant.Type, capturedGrant.Type);
Assert.Equal(grant.SubjectId, capturedGrant.SubjectId);
Assert.Equal(grant.SessionId, capturedGrant.SessionId);
Assert.Equal(grant.ClientId, capturedGrant.ClientId);
Assert.Equal(grant.Description, capturedGrant.Description);
Assert.Equal(grant.CreationTime, capturedGrant.CreationTime);
Assert.Equal(grant.Expiration, capturedGrant.Expiration);
Assert.Equal(grant.ConsumedTime, capturedGrant.ConsumedTime);
Assert.Equal(grant.Data, capturedGrant.Data);
}
private static PersistedGrant CreateTestGrant(string key, DateTime? expiration)
{
return new PersistedGrant
{
Key = key,
Type = "authorization_code",
SubjectId = "test-subject",
ClientId = "test-client",
CreationTime = DateTime.UtcNow,
Expiration = expiration,
Data = "{\"test\":\"data\"}"
};
}
}

View File

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

View File

@@ -1,35 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<IsPackable>false</IsPackable>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1305</WarningsNotAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Scim\Scim.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
@@ -36,7 +37,7 @@ public class PostUserCommandTests
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organizationId).Returns(organization);
sutProvider.GetDependency<IPaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IStripePaymentService>().HasSecretsManagerStandalone(organization).Returns(true);
sutProvider.GetDependency<IOrganizationService>()
.InviteUserAsync(organizationId,

View File

@@ -0,0 +1,952 @@
using System.Net;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Sso.IntegrationTest.Utilities;
using Bit.Test.Common.AutoFixture.Attributes;
using Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc.Testing;
using NSubstitute;
using Xunit;
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
namespace Bit.Sso.IntegrationTest.Controllers;
public class AccountControllerTests(SsoApplicationFactory factory) : IClassFixture<SsoApplicationFactory>
{
private readonly SsoApplicationFactory _factory = factory;
/*
* Test to verify the /Account/ExternalCallback endpoint exists and is reachable.
*/
[Fact]
public async Task ExternalCallback_EndpointExists_ReturnsExpectedStatusCode()
{
// Arrange
var client = _factory.CreateClient();
// Act - Verify the endpoint is accessible (even if it fails due to missing auth)
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - The endpoint should exist and return 500 (not 404) due to missing authentication
Assert.NotEqual(HttpStatusCode.NotFound, response.StatusCode);
}
/*
* Test to verify calling /Account/ExternalCallback without an authentication cookie
* results in an error as expected.
*/
[Fact]
public async Task ExternalCallback_WithNoAuthenticationCookie_ReturnsError()
{
// Arrange
var client = _factory.CreateClient();
// Act - Call ExternalCallback without proper authentication setup
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because there's no external authentication cookie
Assert.False(response.IsSuccessStatusCode);
// The endpoint will throw an exception when authentication fails
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify behavior of /Account/ExternalCallback with PM24579 feature flag
*/
[Theory]
[BitAutoData(true)]
[BitAutoData(false)]
public async Task ExternalCallback_WithPM24579FeatureFlag_AndNoAuthCookie_ReturnsError
(
bool featureFlagEnabled
)
{
// Arrange
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(featureFlagEnabled);
services.AddSingleton(featureService);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert
Assert.False(response.IsSuccessStatusCode);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify behavior of /Account/ExternalCallback simulating failed authentication.
*/
[Fact]
public async Task ExternalCallback_WithMockedAuthenticationService_FailedAuth_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithFailedAuthentication()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert
Assert.False(response.IsSuccessStatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when SSO config exists but is disabled.
*/
[Fact]
public async Task ExternalCallback_WithDisabledSsoConfig_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig(ssoConfig => ssoConfig!.Enabled = false)
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because SSO config is disabled
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
public async Task ExternalCallback_FindUserFromExternalProviderAsync_OrganizationOrSsoConfigNotFound_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user has invalid status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Organization not found or SSO configuration not enabled", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when SSO config expects an ACR value
* but the authentication response has a missing or invalid ACR claim.
*/
[Fact]
public async Task ExternalCallback_WithExpectedAcrValue_AndInvalidAcr_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig(ssoConfig => ssoConfig!.SetData(
new SsoConfigurationData
{
ExpectedReturnAcrValue = "urn:expected:acr:value"
}))
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because ACR claim is missing or invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Expected authentication context class reference (acr) was not returned with the authentication response or is invalid", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when the authentication response
* does not contain any recognizable user ID claim (sub, NameIdentifier, uid, upn, eppn).
*/
[Fact]
public async Task ExternalCallback_WithNoUserIdClaim_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.OmitProviderUserId()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback"); ;
// Assert - Should fail because no user ID claim was found
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Unknown userid", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when no email claim is found
* and the providerUserId cannot be used as a fallback email (doesn't contain @).
*/
[Fact]
public async Task ExternalCallback_WithNoEmailClaim_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithNullEmail()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because no email claim was found
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Cannot find email claim", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* uses Key Connector but has no org user record (was removed from organization).
*/
[Fact]
public async Task ExternalCallback_WithExistingKeyConnectorUser_AndNoOrgUser_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser(user =>
{
user.UsesKeyConnector = true;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user uses Key Connector but has no org user record
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* uses Key Connector and has an org user record in the invited status.
*/
[Fact]
public async Task ExternalCallback_WithExistingKeyConnectorUser_AndInvitedOrgUser_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig(ssoConfig => { })
.WithUser(user =>
{
user.UsesKeyConnector = true;
})
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Invited;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user uses Key Connector but the Org user is in the invited status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("You were removed from the organization managing single sign-on for your account", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* (not using Key Connector) has no org user record - they were removed from the organization.
*/
[Fact]
public async Task ExternalCallback_WithExistingUser_AndNoOrgUser_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user exists but has no org user record
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("You were removed from the organization managing single sign-on for your account. Contact the organization administrator", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when an existing user
* has an org user record with Invited status - they must accept the invite first.
*/
[Fact]
public async Task ExternalCallback_WithExistingUser_AndInvitedOrgUserStatus_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Invited;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user must accept invite before using SSO
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("you must first log in using your master password", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when organization has no available seats
* and cannot auto-scale because it's a self-hosted instance.
*/
[Fact]
public async Task ExternalCallback_WithNoAvailableSeats_OnSelfHosted_ReturnsError()
{
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithOrganization(org =>
{
org.Seats = 5; // Organization has seat limit
})
.AsSelfHosted()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because no seats available and cannot auto-scale on self-hosted
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("No seats available for organization", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when organization has no available seats
* and auto-scaling fails (e.g., billing issue, max seats reached).
*/
[Fact]
public async Task ExternalCallback_WithNoAvailableSeats_AndAutoAddSeatsFails_ReturnsError()
{
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithOrganization(org =>
{
org.Seats = 5;
org.MaxAutoscaleSeats = 5;
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because auto-adding seats failed
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("No seats available for organization", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when email cannot be found
* during new user provisioning (Scenario 2) after bypassing the first email check
* via manual linking path (userIdentifier is set).
*/
[Fact]
public async Task ExternalCallback_WithUserIdentifier_AndNoEmail_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUserIdentifier("")
.WithNullEmail()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because email cannot be found during new user provisioning
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Cannot find email claim", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when org user has an unknown/invalid status.
* This tests defensive code that handles future enum values or data corruption scenarios.
* We simulate this by casting an invalid integer to OrganizationUserStatusType.
*/
[Fact]
public async Task ExternalCallback_WithUnknownOrgUserStatus_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = (OrganizationUserStatusType)99; // Invalid enum value - simulates future status or data corruption
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because org user status is unknown/invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("is in an unknown state", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
// Note: "User should be found ln 304" appears to be unreachable defensive code.
// CreateUserAndOrgUserConditionallyAsync always returns a non-null user or throws an exception,
// so possibleSsoLinkedUser cannot be null when the feature flag check executes.
/*
* Test to verify /Account/ExternalCallback returns error when userIdentifier
* is malformed (doesn't contain comma separator for userId,token format).
* There is only a single test case here but in the future we may need to expand the
* tests to cover other invalid formats.
*/
[Theory]
[BitAutoData("No-Comas-Identifier")]
public async Task ExternalCallback_WithInvalidUserIdentifierFormat_ReturnsError(
string UserIdentifier
)
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUserIdentifier(UserIdentifier)
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because userIdentifier format is invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Invalid user identifier", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when userIdentifier
* contains valid userId but invalid/mismatched token.
*
* NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because:
* - The userIdentifier in the auth result must contain a userId that matches a user in the system
* - User.SetNewId() always overwrites the Id (unlike Organization.SetNewId() which has a guard)
* - This means we cannot pre-set a User.Id before database insertion
* - The auth mock must be configured BEFORE accessing factory.Services (required by SubstituteService)
* - Therefore, we cannot coordinate the userId between the auth mock and the seeded user
* - Using substitutes allows us to control the exact userId and mock UserManager.VerifyUserTokenAsync
*/
[Fact]
public async Task ExternalCallback_WithUserIdentifier_AndInvalidToken_ReturnsError()
{
// Arrange
var organizationId = Guid.NewGuid();
var providerUserId = Guid.NewGuid().ToString();
var userId = Guid.NewGuid();
var testEmail = "test_user@integration.test";
var testName = "Test User";
// Valid format but token won't verify
var userIdentifier = $"{userId},invalid-token";
var claimedUser = new User
{
Id = userId,
Email = testEmail,
Name = testName
};
var organization = new Organization
{
Id = organizationId,
Name = "Test Organization",
Enabled = true,
UseSso = true
};
var ssoConfig = new SsoConfig
{
OrganizationId = organizationId,
Enabled = true
};
ssoConfig.SetData(new SsoConfigurationData());
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var featureService = Substitute.For<IFeatureService>();
featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
services.AddSingleton(featureService);
// Mock organization repository
var orgRepo = Substitute.For<IOrganizationRepository>();
orgRepo.GetByIdAsync(organizationId).Returns(organization);
orgRepo.GetByIdentifierAsync(organizationId.ToString()).Returns(organization);
services.AddSingleton(orgRepo);
// Mock SSO config repository
var ssoConfigRepo = Substitute.For<ISsoConfigRepository>();
ssoConfigRepo.GetByOrganizationIdAsync(organizationId).Returns(ssoConfig);
services.AddSingleton(ssoConfigRepo);
// Mock user repository - no existing user via SSO
var userRepo = Substitute.For<IUserRepository>();
userRepo.GetBySsoUserAsync(providerUserId, organizationId).Returns((User?)null);
services.AddSingleton(userRepo);
// Mock user service - returns user for manual linking lookup
var userService = Substitute.For<IUserService>();
userService.GetUserByIdAsync(userId.ToString()).Returns(claimedUser);
services.AddSingleton(userService);
// Mock UserManager to return false for token verification
var userManager = Substitute.For<UserManager<User>>(
Substitute.For<IUserStore<User>>(), null, null, null, null, null, null, null, null);
userManager.VerifyUserTokenAsync(
claimedUser,
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>())
.Returns(false);
services.AddSingleton(userManager);
// Mock authentication service with userIdentifier that has valid format but invalid token
var authService = Substitute.For<IAuthenticationService>();
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(organizationId, providerUserId, testEmail, testName, null, userIdentifier));
services.AddSingleton(authService);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because token verification failed
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Supplied userId and token did not match", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is enabled.
*/
[Fact]
public async Task ExternalCallback_WithRevokedOrgUser_WithPM24579FeatureFlagEnabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Revoked;
})
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user state is invalid
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains(
$"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.",
stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error for revoked org user when PM24579 feature flag is disabled.
*/
[Fact]
public async Task ExternalCallback_WithRevokedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Revoked;
})
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user has invalid status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains(
$"Your access to organization {testData.Organization?.DisplayName()} has been revoked. Please contact your administrator for assistance.",
stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error for invited org user when PM24579 feature flag is disabled.
*/
[Fact]
public async Task ExternalCallback_WithInvitedOrgUserStatus_WithPM24579FeatureFlagDisabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Invited;
})
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(false);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because user has invalid status
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains(
$"To accept your invite to {testData.Organization?.DisplayName()}, you must first log in using your master password. Once your invite has been accepted, you will be able to log in using SSO.",
stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when user is found via SSO
* but has no organization user record (with feature flag enabled).
*/
[Fact]
public async Task ExternalCallback_WithSsoUser_AndNoOrgUser_WithFeatureFlagEnabled_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithSsoUser()
.WithFeatureFlags(factoryService =>
{
factoryService.SubstituteService<IFeatureService>(srv =>
{
srv.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers).Returns(true);
});
})
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because org user cannot be found
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Could not find organization user", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when the provider scheme
* is not a valid GUID (SSOProviderIsNotAnOrgId).
*
* NOTE: This test uses the substitute pattern instead of SsoTestDataBuilder because:
* - Organization.Id is of type Guid and cannot be set to a non-GUID value
* - The auth mock scheme must be a non-GUID string to trigger this error path
* - This cannot be tested since ln 438 in AccountController.FindUserFromExternalProviderAsync throws a different exception
* before reaching the organization lookup exception.
*/
[Fact(Skip = "This test cannot be executed because the organization ID must be a GUID. See note in test summary.")]
public async Task ExternalCallback_WithInvalidProviderGuid_ReturnsError()
{
// Arrange
var invalidScheme = "not-a-valid-guid";
var providerUserId = Guid.NewGuid().ToString();
var testEmail = "test@example.com";
var testName = "Test User";
var client = _factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
// Mock authentication service with invalid (non-GUID) scheme
var authService = Substitute.For<IAuthenticationService>();
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(invalidScheme, providerUserId, testEmail, testName));
services.AddSingleton(authService);
});
}).CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because provider is not a valid organization GUID
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Organization not found from identifier.", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* Test to verify /Account/ExternalCallback returns error when the organization ID
* in the auth result does not match any organization in the database.
* NOTE: This code path is unreachable because the SsoConfig must exist to proceed, but there is a circular dependency:
* - SsoConfig cannot exist without a valid Organization but the test is testing that an Organization cannot be found.
*/
[Fact(Skip = "This code path is unreachable because the SsoConfig must exist to proceed. But the SsoConfig cannot exist without a valid Organization.")]
public async Task ExternalCallback_WithNonExistentOrganization_ReturnsError()
{
// Arrange
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithNonExistentOrganizationInAuth()
.BuildAsync();
var client = testData.Factory.CreateClient();
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should fail because organization cannot be found by the ID in auth result
var stringResponse = await response.Content.ReadAsStringAsync();
Assert.Contains("Could not find organization", stringResponse);
Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing
* SSO-linked user logs in (user exists in SsoUser table).
*/
[Fact]
public async Task ExternalCallback_WithExistingSsoUser_ReturnsSuccess()
{
// Arrange - User with SSO link already exists
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser()
.WithSsoUser()
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when JIT provisioning
* a new user (user doesn't exist, gets created automatically).
*/
[Fact]
public async Task ExternalCallback_WithJitProvisioning_ReturnsSuccess()
{
// Arrange - No user, no org user - JIT provisioning will create both
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user
* with a valid (Confirmed) organization user status logs in via SSO for the first time.
*/
[Fact]
public async Task ExternalCallback_WithExistingUserAndConfirmedOrgUser_ReturnsSuccess()
{
// Arrange - Existing user with confirmed org user status, no SSO link yet
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Confirmed;
})
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback succeeds when an existing user
* with Accepted organization user status logs in via SSO.
*/
[Fact]
public async Task ExternalCallback_WithExistingUserAndAcceptedOrgUser_ReturnsSuccess()
{
// Arrange - Existing user with accepted org user status
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser(orgUser =>
{
orgUser.Status = OrganizationUserStatusType.Accepted;
})
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false // Prevent auto-redirects to capture initial response
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Should succeed and redirect
Assert.True(
response.StatusCode == HttpStatusCode.Redirect,
$"Expected success/redirect but got {response.StatusCode}");
Assert.NotNull(response.Headers.Location);
}
/*
* SUCCESS PATH: Test to verify /Account/ExternalCallback returns a View with 200 status
* when the client is a native application (uses custom URI scheme like "bitwarden://callback").
* Native clients get a different response for better UX - a 200 with redirect view instead of 302.
* See AccountController lines 371-378.
*/
[Fact]
public async Task ExternalCallback_WithNativeClient_ReturnsViewWith200Status()
{
// Arrange - Existing SSO user with native client context
var testData = await new SsoTestDataBuilder()
.WithSsoConfig()
.WithUser()
.WithOrganizationUser()
.WithSsoUser()
.AsNativeClient()
.BuildAsync();
var client = testData.Factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Act
var response = await client.GetAsync("/Account/ExternalCallback");
// Assert - Native clients get 200 status with a redirect view instead of 302
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
// The Location header should be empty for native clients (set in controller)
// and the response should contain the redirect view
var content = await response.Content.ReadAsStringAsync();
Assert.NotEmpty(content); // View content should be present
}
}

View File

@@ -0,0 +1,12 @@
{
"profiles": {
"Sso.IntegrationTest": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:59973;http://localhost:59974"
}
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="AutoFixture.Xunit2" Version="$(AutoFixtureXUnit2Version)" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
<ProjectReference Include="..\..\..\test\IntegrationTestCommon\IntegrationTestCommon.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="Properties\launchSettings.json">
<ExcludeFromSingleFile>true</ExcludeFromSingleFile>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,11 @@
using Bit.IntegrationTestCommon.Factories;
namespace Bit.Sso.IntegrationTest.Utilities;
public class SsoApplicationFactory : WebApplicationFactoryBase<Startup>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
}
}

View File

@@ -0,0 +1,327 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Auth.Entities;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Auth.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Services;
using Microsoft.AspNetCore.Authentication;
using NSubstitute;
using AuthenticationSchemes = Bit.Core.AuthenticationSchemes;
namespace Bit.Sso.IntegrationTest.Utilities;
/// <summary>
/// Contains the factory and all entities created by <see cref="SsoTestDataBuilder"/> for use in integration tests.
/// </summary>
public record SsoTestData(
SsoApplicationFactory Factory,
Organization? Organization,
User? User,
OrganizationUser? OrganizationUser,
SsoConfig? SsoConfig,
SsoUser? SsoUser);
/// <summary>
/// Builder for creating SSO test data with seeded database entities.
/// </summary>
public class SsoTestDataBuilder
{
/// <summary>
/// This UserIdentifier is a mock for the UserIdentifier we get from the External Identity Provider.
/// </summary>
private string? _userIdentifier;
private Action<Organization>? _organizationConfig;
private Action<User>? _userConfig;
private Action<OrganizationUser>? _orgUserConfig;
private Action<SsoConfig>? _ssoConfigConfig;
private Action<SsoUser>? _ssoUserConfig;
private Action<SsoApplicationFactory>? _featureFlagConfig;
private bool _includeUser = false;
private bool _includeSsoUser = false;
private bool _includeOrganizationUser = false;
private bool _includeSsoConfig = false;
private bool _successfulAuth = true;
private bool _withNullEmail = false;
private bool _isSelfHosted = false;
private bool _includeProviderUserId = true;
private bool _useNonExistentOrgInAuth = false;
private bool _isNativeClient = false;
public SsoTestDataBuilder WithOrganization(Action<Organization> configure)
{
_organizationConfig = configure;
return this;
}
public SsoTestDataBuilder WithUser(Action<User>? configure = null)
{
_includeUser = true;
_userConfig = configure;
return this;
}
public SsoTestDataBuilder WithOrganizationUser(Action<OrganizationUser>? configure = null)
{
_includeOrganizationUser = true;
_orgUserConfig = configure;
return this;
}
public SsoTestDataBuilder WithSsoConfig(Action<SsoConfig>? configure = null)
{
_includeSsoConfig = true;
_ssoConfigConfig = configure;
return this;
}
public SsoTestDataBuilder WithSsoUser(Action<SsoUser>? configure = null)
{
_includeSsoUser = true;
_ssoUserConfig = configure;
return this;
}
public SsoTestDataBuilder WithFeatureFlags(Action<SsoApplicationFactory> configure)
{
_featureFlagConfig = configure;
return this;
}
public SsoTestDataBuilder WithFailedAuthentication()
{
_successfulAuth = false;
return this;
}
public SsoTestDataBuilder WithNullEmail()
{
_withNullEmail = true;
return this;
}
public SsoTestDataBuilder WithUserIdentifier(string userIdentifier)
{
_userIdentifier = userIdentifier;
return this;
}
public SsoTestDataBuilder OmitProviderUserId()
{
_includeProviderUserId = false;
return this;
}
public SsoTestDataBuilder AsSelfHosted()
{
_isSelfHosted = true;
return this;
}
/// <summary>
/// Causes the auth result to use a different (non-existent) organization ID than what is seeded
/// in the database. This simulates the "organization not found" scenario.
/// </summary>
public SsoTestDataBuilder WithNonExistentOrganizationInAuth()
{
_useNonExistentOrgInAuth = true;
return this;
}
/// <summary>
/// Configures the test to simulate a native client (non-browser) OIDC flow.
/// Native clients use custom URI schemes (e.g., "bitwarden://callback") instead of http/https.
/// This causes ExternalCallback to return a View with 200 status instead of a redirect.
/// </summary>
public SsoTestDataBuilder AsNativeClient()
{
_isNativeClient = true;
return this;
}
public async Task<SsoTestData> BuildAsync()
{
// Create factory
var factory = new SsoApplicationFactory();
// Pre-generate IDs and values needed for auth mock (before accessing Services)
var organizationId = Guid.NewGuid();
// Use a different org ID in auth if testing "organization not found" scenario
var authOrganizationId = _useNonExistentOrgInAuth ? Guid.NewGuid() : organizationId;
var providerUserId = _includeProviderUserId ? Guid.NewGuid().ToString() : "";
var userEmail = _withNullEmail ? null : $"user_{Guid.NewGuid()}@test.com";
var userName = "TestUser";
// 1. Configure mocked authentication service BEFORE accessing Services
factory.SubstituteService<IAuthenticationService>(authService =>
{
if (_successfulAuth)
{
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(MockSuccessfulAuthResult.Build(
authOrganizationId,
providerUserId,
userEmail,
userName,
acrValue: null,
_userIdentifier));
}
else
{
authService.AuthenticateAsync(
Arg.Any<HttpContext>(),
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme)
.Returns(AuthenticateResult.Fail("External authentication error"));
}
});
// 1.a Configure GlobalSettings for Self-Hosted and seat limit
factory.SubstituteService<IGlobalSettings>(globalSettings =>
{
globalSettings.SelfHosted.Returns(_isSelfHosted);
});
// 1.b configure setting feature flags
_featureFlagConfig?.Invoke(factory);
// 1.c Configure IIdentityServerInteractionService for native client flow
if (_isNativeClient)
{
factory.SubstituteService<IIdentityServerInteractionService>(interaction =>
{
// Native clients have redirect URIs that don't start with http/https
// e.g., "bitwarden://callback" or "com.bitwarden.app://callback"
var authorizationRequest = new AuthorizationRequest
{
RedirectUri = "bitwarden://sso-callback"
};
interaction.GetAuthorizationContextAsync(Arg.Any<string>())
.Returns(authorizationRequest);
});
}
if (!_successfulAuth)
{
return new SsoTestData(factory, null!, null!, null!, null!, null!);
}
// 2. Create Organization with defaults (using pre-generated ID)
var organization = new Organization
{
Id = organizationId,
Name = "Test Organization",
BillingEmail = "billing@test.com",
Plan = "Enterprise",
Enabled = true,
UseSso = true
};
_organizationConfig?.Invoke(organization);
var orgRepo = factory.Services.GetRequiredService<IOrganizationRepository>();
organization = await orgRepo.CreateAsync(organization);
// 3. Create User with defaults (using pre-generated values)
User? user = null;
if (_includeUser)
{
user = new User
{
Email = userEmail ?? $"email_{Guid.NewGuid()}@test.dev",
Name = userName,
ApiKey = Guid.NewGuid().ToString(),
SecurityStamp = Guid.NewGuid().ToString()
};
_userConfig?.Invoke(user);
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
user = await userRepo.CreateAsync(user);
}
// 4. Create OrganizationUser linking them
OrganizationUser? orgUser = null;
if (_includeOrganizationUser)
{
orgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = user!.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User
};
_orgUserConfig?.Invoke(orgUser);
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
orgUser = await orgUserRepo.CreateAsync(orgUser);
}
// 4.a Create many OrganizationUser to test seat count logic
if (organization.Seats > 1)
{
var orgUserRepo = factory.Services.GetRequiredService<IOrganizationUserRepository>();
var userRepo = factory.Services.GetRequiredService<IUserRepository>();
var additionalOrgUsers = new List<OrganizationUser>();
for (var i = 1; i <= organization.Seats; i++)
{
var additionalUser = new User
{
Email = $"additional_user_{i}_{Guid.NewGuid()}@test.dev",
Name = $"AdditionalUser{i}",
ApiKey = Guid.NewGuid().ToString(),
SecurityStamp = Guid.NewGuid().ToString()
};
var createdAdditionalUser = await userRepo.CreateAsync(additionalUser);
var additionalOrgUser = new OrganizationUser
{
OrganizationId = organization.Id,
UserId = createdAdditionalUser.Id,
Status = OrganizationUserStatusType.Confirmed,
Type = OrganizationUserType.User
};
additionalOrgUsers.Add(additionalOrgUser);
}
await orgUserRepo.CreateManyAsync(additionalOrgUsers);
}
// 5. Create SsoConfig, if ssoConfigConfig is not null
SsoConfig? ssoConfig = null;
if (_includeSsoConfig)
{
ssoConfig = new SsoConfig
{
OrganizationId = authOrganizationId,
Enabled = true
};
ssoConfig.SetData(new SsoConfigurationData());
_ssoConfigConfig?.Invoke(ssoConfig);
var ssoConfigRepo = factory.Services.GetRequiredService<ISsoConfigRepository>();
ssoConfig = await ssoConfigRepo.CreateAsync(ssoConfig);
}
// 6. Optionally create SsoUser (using pre-generated providerUserId as ExternalId)
SsoUser? ssoUser = null;
if (_includeSsoUser)
{
ssoUser = new SsoUser
{
OrganizationId = organization.Id,
UserId = user!.Id,
ExternalId = providerUserId
};
_ssoUserConfig?.Invoke(ssoUser);
var ssoUserRepo = factory.Services.GetRequiredService<ISsoUserRepository>();
ssoUser = await ssoUserRepo.CreateAsync(ssoUser);
}
return new SsoTestData(factory, organization, user, orgUser, ssoConfig, ssoUser);
}
}

View File

@@ -0,0 +1,88 @@
using System.Security.Claims;
using Bit.Core;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Authentication;
namespace Bitwarden.License.Test.Sso.IntegrationTest.Utilities;
/// <summary>
/// Creates a mock for use in tests requiring a valid external authentication result.
/// </summary>
internal static class MockSuccessfulAuthResult
{
/// <summary>
/// Since this tests the external Authentication flow, only the OrganizationId is strictly required.
/// However, some tests may require additional claims to be present, so they can be optionally added.
/// </summary>
/// <param name="organizationId"></param>
/// <param name="providerUserId"></param>
/// <param name="email"></param>
/// <param name="name"></param>
/// <param name="acrValue"></param>
/// <param name="userIdentifier"></param>
/// <returns></returns>
public static AuthenticateResult Build(
Guid organizationId,
string? providerUserId,
string? email,
string? name = null,
string? acrValue = null,
string? userIdentifier = null)
{
return Build(organizationId.ToString(), providerUserId, email, name, acrValue, userIdentifier);
}
/// <summary>
/// Overload that accepts a custom scheme string. Useful for testing invalid provider scenarios
/// where the scheme is not a valid GUID.
/// </summary>
public static AuthenticateResult Build(
string scheme,
string? providerUserId,
string? email,
string? name = null,
string? acrValue = null,
string? userIdentifier = null)
{
var claims = new List<Claim>();
if (!string.IsNullOrEmpty(email))
{
claims.Add(new Claim(JwtClaimTypes.Email, email));
}
if (!string.IsNullOrEmpty(providerUserId))
{
claims.Add(new Claim(JwtClaimTypes.Subject, providerUserId));
}
if (!string.IsNullOrEmpty(name))
{
claims.Add(new Claim(JwtClaimTypes.Name, name));
}
if (!string.IsNullOrEmpty(acrValue))
{
claims.Add(new Claim(JwtClaimTypes.AuthenticationContextClassReference, acrValue));
}
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, "External"));
var properties = new AuthenticationProperties
{
Items =
{
["scheme"] = scheme,
["return_url"] = "~/",
["state"] = "test-state",
["user_identifier"] = userIdentifier ?? string.Empty
}
};
var ticket = new AuthenticationTicket(
principal,
properties,
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
return AuthenticateResult.Success(ticket);
}
}

View File

@@ -99,7 +99,7 @@ services:
- idp
rabbitmq:
image: rabbitmq:4.1.3-management
image: rabbitmq:4.2.0-management
ports:
- "5672:5672"
- "15672:15672"

View File

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

View File

@@ -33,8 +33,20 @@
"id": "<your Installation Id>",
"key": "<your Installation Key>"
},
"events": {
"connectionString": "",
"queueName": "event"
},
"licenseDirectory": "<full path to license directory>",
"enableNewDeviceVerification": true,
"enableEmailVerification": true
"enableEmailVerification": true,
"communication": {
"bootstrap": "none",
"ssoCookieVendor": {
"idpLoginUrl": "",
"cookieName": "",
"cookieDomain": ""
}
}
}
}

23
dev/setup_secrets.ps1 Normal file → Executable file
View File

@@ -2,7 +2,7 @@
# Helper script for applying the same user secrets to each project
param (
[switch]$clear,
[Parameter(ValueFromRemainingArguments = $true, Position=1)]
[Parameter(ValueFromRemainingArguments = $true, Position = 1)]
$cmdArgs
)
@@ -16,17 +16,18 @@ if ($clear -eq $true) {
}
$projects = @{
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
Admin = "../src/Admin"
Api = "../src/Api"
Billing = "../src/Billing"
Events = "../src/Events"
EventsProcessor = "../src/EventsProcessor"
Icons = "../src/Icons"
Identity = "../src/Identity"
Notifications = "../src/Notifications"
Sso = "../bitwarden_license/src/Sso"
Scim = "../bitwarden_license/src/Scim"
IntegrationTests = "../test/Infrastructure.IntegrationTest"
SeederApi = "../util/SeederApi"
}
foreach ($key in $projects.keys) {

352
dev/verify_migrations.ps1 Normal file
View File

@@ -0,0 +1,352 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Validates that new database migration files follow naming conventions and chronological order.
.DESCRIPTION
This script validates migration files to ensure:
For SQL migrations in util/Migrator/DbScripts/:
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)
For Entity Framework migrations in util/MySqlMigrations, util/PostgresMigrations, util/SqliteMigrations:
1. New migrations follow the naming format: YYYYMMDDHHMMSS_Description.cs
2. Each migration has both .cs and .Designer.cs files
3. New migrations are chronologically ordered (timestamp sorts after existing migrations)
.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/" 2>$null | Where-Object { $_ -like "*.sql" } | 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/" | Where-Object { $_ -like "*.sql" } | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
$sqlValidationFailed = $false
if ($addedMigrations.Count -eq 0) {
Write-Host "No new SQL migration files added."
Write-Host ""
}
else {
Write-Host "New SQL 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 SQL migrations found (initial commit?). Skipping chronological validation."
Write-Host ""
}
else {
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last SQL 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$'
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"
$sqlValidationFailed = $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'"
$sqlValidationFailed = $true
}
else {
Write-Host "OK: '$migrationName' is chronologically after '$lastBaseMigration'"
}
}
Write-Host ""
}
if ($sqlValidationFailed) {
Write-Host "FAILED: One or more SQL migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new SQL 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"
}
else {
Write-Host "SUCCESS: All new SQL migrations are correctly named and in chronological order"
}
Write-Host ""
}
# ===========================================================================================
# Validate Entity Framework Migrations
# ===========================================================================================
Write-Host "==================================================================="
Write-Host "Validating Entity Framework Migrations"
Write-Host "==================================================================="
Write-Host ""
$efMigrationPaths = @(
@{ Path = "util/MySqlMigrations/Migrations"; Name = "MySQL" },
@{ Path = "util/PostgresMigrations/Migrations"; Name = "Postgres" },
@{ Path = "util/SqliteMigrations/Migrations"; Name = "SQLite" }
)
$efValidationFailed = $false
foreach ($migrationPathInfo in $efMigrationPaths) {
$efPath = $migrationPathInfo.Path
$dbName = $migrationPathInfo.Name
Write-Host "-------------------------------------------------------------------"
Write-Host "Checking $dbName EF migrations in $efPath"
Write-Host "-------------------------------------------------------------------"
Write-Host ""
# Get list of migrations from base reference
try {
$baseMigrations = git ls-tree -r --name-only $BaseRef -- "$efPath/" 2>$null | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
if ($LASTEXITCODE -ne 0) {
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
}
catch {
Write-Host "Warning: Could not retrieve $dbName migrations from base reference '$BaseRef'"
$baseMigrations = @()
}
# Get list of migrations from current reference
$currentMigrations = git ls-tree -r --name-only $CurrentRef -- "$efPath/" | Where-Object { $_ -like "*.cs" -and $_ -notlike "*DatabaseContextModelSnapshot.cs" } | Sort-Object
# Find added migrations
$addedMigrations = $currentMigrations | Where-Object { $_ -notin $baseMigrations }
if ($addedMigrations.Count -eq 0) {
Write-Host "No new $dbName EF migration files added."
Write-Host ""
continue
}
Write-Host "New $dbName EF 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 $dbName migrations found. Skipping chronological validation."
Write-Host ""
}
else {
$lastBaseMigration = Split-Path -Leaf ($baseMigrations | Select-Object -Last 1)
Write-Host "Last $dbName migration in base reference: $lastBaseMigration"
Write-Host ""
}
# Required format regex: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs
$efFormatRegex = '^[0-9]{14}_.+\.cs$'
# Group migrations by base name (without .Designer.cs suffix)
$migrationGroups = @{}
$unmatchedFiles = @()
foreach ($migration in $addedMigrations) {
$migrationName = Split-Path -Leaf $migration
# Extract base name (remove .Designer.cs or .cs)
if ($migrationName -match '^([0-9]{14}_.+?)(?:\.Designer)?\.cs$') {
$baseName = $matches[1]
if (-not $migrationGroups.ContainsKey($baseName)) {
$migrationGroups[$baseName] = @()
}
$migrationGroups[$baseName] += $migrationName
}
else {
# Track files that don't match the expected pattern
$unmatchedFiles += $migrationName
}
}
# Flag any files that don't match the expected pattern
if ($unmatchedFiles.Count -gt 0) {
Write-Host "ERROR: The following migration files do not match the required format:"
foreach ($unmatchedFile in $unmatchedFiles) {
Write-Host " - $unmatchedFile"
}
Write-Host ""
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs or YYYYMMDDHHMMSS_Description.Designer.cs"
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
Write-Host " - Description: Descriptive name using PascalCase"
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
Write-Host ""
$efValidationFailed = $true
}
foreach ($baseName in $migrationGroups.Keys | Sort-Object) {
$files = $migrationGroups[$baseName]
# Validate format
$mainFile = "$baseName.cs"
$designerFile = "$baseName.Designer.cs"
if ($mainFile -notmatch $efFormatRegex) {
Write-Host "ERROR: Migration '$mainFile' does not match required format"
Write-Host "Required format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " - YYYYMMDDHHMMSS: 14-digit timestamp (Year, Month, Day, Hour, Minute, Second)"
Write-Host "Example: 20250115120000_AddNewFeature.cs"
$efValidationFailed = $true
continue
}
# Check that both .cs and .Designer.cs files exist
$hasCsFile = $files -contains $mainFile
$hasDesignerFile = $files -contains $designerFile
if (-not $hasCsFile) {
Write-Host "ERROR: Missing main migration file: $mainFile"
$efValidationFailed = $true
}
if (-not $hasDesignerFile) {
Write-Host "ERROR: Missing designer file: $designerFile"
Write-Host "Each EF migration must have both a .cs and .Designer.cs file"
$efValidationFailed = $true
}
if (-not $hasCsFile -or -not $hasDesignerFile) {
continue
}
# Compare migration timestamp with last base migration (using ordinal string comparison)
if ($baseMigrations.Count -gt 0) {
if ([string]::CompareOrdinal($mainFile, $lastBaseMigration) -lt 0) {
Write-Host "ERROR: New migration '$mainFile' is not chronologically after '$lastBaseMigration'"
$efValidationFailed = $true
}
else {
Write-Host "OK: '$mainFile' is chronologically after '$lastBaseMigration'"
}
}
else {
Write-Host "OK: '$mainFile' (no previous migrations to compare)"
}
}
Write-Host ""
}
if ($efValidationFailed) {
Write-Host "FAILED: One or more EF migrations are incorrectly named or not in chronological order"
Write-Host ""
Write-Host "All new EF migration files must:"
Write-Host " 1. Follow the naming format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " 2. Include both .cs and .Designer.cs files"
Write-Host " 3. Have a timestamp 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 the respective Migrations directory"
Write-Host " 2. Ensure both .cs and .Designer.cs files exist"
Write-Host " 3. Rename to follow format: YYYYMMDDHHMMSS_Description.cs"
Write-Host " 4. Ensure the timestamp is after the last migration"
Write-Host ""
Write-Host "Example: 20250115120000_AddNewFeature.cs and 20250115120000_AddNewFeature.Designer.cs"
}
else {
Write-Host "SUCCESS: All new EF migrations are correctly named and in chronological order"
}
Write-Host ""
Write-Host "==================================================================="
Write-Host "Validation Summary"
Write-Host "==================================================================="
if ($sqlValidationFailed -or $efValidationFailed) {
if ($sqlValidationFailed) {
Write-Host "❌ SQL migrations validation FAILED"
}
else {
Write-Host "✓ SQL migrations validation PASSED"
}
if ($efValidationFailed) {
Write-Host "❌ EF migrations validation FAILED"
}
else {
Write-Host "✓ EF migrations validation PASSED"
}
Write-Host ""
Write-Host "OVERALL RESULT: FAILED"
exit 1
}
else {
Write-Host "✓ SQL migrations validation PASSED"
Write-Host "✓ EF migrations validation PASSED"
Write-Host ""
Write-Host "OVERALL RESULT: SUCCESS"
exit 0
}

View File

@@ -5,6 +5,7 @@
},
"msbuild-sdks": {
"Microsoft.Build.Traversal": "4.1.0",
"Microsoft.Build.Sql": "1.0.0"
"Microsoft.Build.Sql": "1.0.0",
"Bitwarden.Server.Sdk": "1.2.0"
}
}

View File

@@ -2,6 +2,8 @@
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>
<!-- These opt outs should be removed when all warnings are addressed -->
<WarningsNotAsErrors>$(WarningsNotAsErrors);CA1304;CA1305</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup Condition=" '$(RunConfiguration)' == 'Admin' " />

View File

@@ -14,8 +14,10 @@ using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Organizations.Services;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Providers.Services;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.OrganizationFeatures.OrganizationSponsorships.FamiliesForEnterprise.Interfaces;
@@ -41,7 +43,7 @@ public class OrganizationsController : Controller
private readonly ICollectionRepository _collectionRepository;
private readonly IGroupRepository _groupRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IPaymentService _paymentService;
private readonly IStripePaymentService _paymentService;
private readonly IApplicationCacheService _applicationCacheService;
private readonly GlobalSettings _globalSettings;
private readonly IProviderRepository _providerRepository;
@@ -56,6 +58,7 @@ public class OrganizationsController : Controller
private readonly IOrganizationInitiateDeleteCommand _organizationInitiateDeleteCommand;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IOrganizationBillingService _organizationBillingService;
public OrganizationsController(
IOrganizationRepository organizationRepository,
@@ -66,7 +69,7 @@ public class OrganizationsController : Controller
ICollectionRepository collectionRepository,
IGroupRepository groupRepository,
IPolicyRepository policyRepository,
IPaymentService paymentService,
IStripePaymentService paymentService,
IApplicationCacheService applicationCacheService,
GlobalSettings globalSettings,
IProviderRepository providerRepository,
@@ -80,7 +83,8 @@ public class OrganizationsController : Controller
IProviderBillingService providerBillingService,
IOrganizationInitiateDeleteCommand organizationInitiateDeleteCommand,
IPricingClient pricingClient,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IOrganizationBillingService organizationBillingService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -105,6 +109,7 @@ public class OrganizationsController : Controller
_organizationInitiateDeleteCommand = organizationInitiateDeleteCommand;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_organizationBillingService = organizationBillingService;
}
[RequirePermission(Permission.Org_List_View)]
@@ -241,6 +246,8 @@ public class OrganizationsController : Controller
var existingOrganizationData = new Organization
{
Id = organization.Id,
Name = organization.Name,
BillingEmail = organization.BillingEmail,
Status = organization.Status,
PlanType = organization.PlanType,
Seats = organization.Seats
@@ -286,6 +293,22 @@ public class OrganizationsController : Controller
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
// Sync name/email changes to Stripe
if (existingOrganizationData.Name != organization.Name || existingOrganizationData.BillingEmail != organization.BillingEmail)
{
try
{
await _organizationBillingService.UpdateOrganizationNameAndEmail(organization);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to update Stripe customer for organization {OrganizationId}. Database was updated successfully.",
organization.Id);
TempData["Warning"] = "Organization updated successfully, but Stripe customer name/email synchronization failed.";
}
}
return RedirectToAction("Edit", new { id });
}
@@ -473,6 +496,8 @@ public class OrganizationsController : Controller
organization.UseOrganizationDomains = model.UseOrganizationDomains;
organization.UseAdminSponsoredFamilies = model.UseAdminSponsoredFamilies;
organization.UseAutomaticUserConfirmation = model.UseAutomaticUserConfirmation;
organization.UseDisableSmAdsForUsers = model.UseDisableSmAdsForUsers;
organization.UsePhishingBlocker = model.UsePhishingBlocker;
//secrets
organization.SmSeats = model.SmSeats;

View File

@@ -56,6 +56,7 @@ public class ProvidersController : Controller
private readonly IStripeAdapter _stripeAdapter;
private readonly IAccessControlService _accessControlService;
private readonly ISubscriberService _subscriberService;
private readonly ILogger<ProvidersController> _logger;
public ProvidersController(IOrganizationRepository organizationRepository,
IResellerClientOrganizationSignUpCommand resellerClientOrganizationSignUpCommand,
@@ -72,7 +73,8 @@ public class ProvidersController : Controller
IPricingClient pricingClient,
IStripeAdapter stripeAdapter,
IAccessControlService accessControlService,
ISubscriberService subscriberService)
ISubscriberService subscriberService,
ILogger<ProvidersController> logger)
{
_organizationRepository = organizationRepository;
_resellerClientOrganizationSignUpCommand = resellerClientOrganizationSignUpCommand;
@@ -92,6 +94,7 @@ public class ProvidersController : Controller
_braintreeMerchantUrl = webHostEnvironment.GetBraintreeMerchantUrl();
_braintreeMerchantId = globalSettings.Braintree.MerchantId;
_subscriberService = subscriberService;
_logger = logger;
}
[RequirePermission(Permission.Provider_List_View)]
@@ -296,6 +299,9 @@ public class ProvidersController : Controller
var originalProviderStatus = provider.Enabled;
// Capture original billing email before modifications for Stripe sync
var originalBillingEmail = provider.BillingEmail;
model.ToProvider(provider);
// validate the stripe ids to prevent saving a bad one
@@ -321,6 +327,22 @@ public class ProvidersController : Controller
await _providerService.UpdateAsync(provider);
await _applicationCacheService.UpsertProviderAbilityAsync(provider);
// Sync billing email changes to Stripe
if (!string.IsNullOrEmpty(provider.GatewayCustomerId) && originalBillingEmail != provider.BillingEmail)
{
try
{
await _providerBillingService.UpdateProviderNameAndEmail(provider);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to update Stripe customer for provider {ProviderId}. Database was updated successfully.",
provider.Id);
TempData["Warning"] = "Provider updated successfully, but Stripe customer email synchronization failed.";
}
}
if (!provider.IsBillable())
{
return RedirectToAction("Edit", new { id });
@@ -339,11 +361,11 @@ public class ProvidersController : Controller
]);
await _providerBillingService.UpdateSeatMinimums(updateMspSeatMinimumsCommand);
var customer = await _stripeAdapter.CustomerGetAsync(provider.GatewayCustomerId);
var customer = await _stripeAdapter.GetCustomerAsync(provider.GatewayCustomerId);
if (model.PayByInvoice != customer.ApprovedToPayByInvoice())
{
var approvedToPayByInvoice = model.PayByInvoice ? "1" : "0";
await _stripeAdapter.CustomerUpdateAsync(customer.Id, new CustomerUpdateOptions
await _stripeAdapter.UpdateCustomerAsync(customer.Id, new CustomerUpdateOptions
{
Metadata = new Dictionary<string, string>
{

View File

@@ -107,6 +107,8 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
UseOrganizationDomains = org.UseOrganizationDomains;
UseAutomaticUserConfirmation = org.UseAutomaticUserConfirmation;
UseDisableSmAdsForUsers = org.UseDisableSmAdsForUsers;
UsePhishingBlocker = org.UsePhishingBlocker;
_plans = plans;
}
@@ -160,6 +162,8 @@ public class OrganizationEditModel : OrganizationViewModel
public new bool UseSecretsManager { get; set; }
[Display(Name = "Risk Insights")]
public new bool UseRiskInsights { get; set; }
[Display(Name = "Phishing Blocker")]
public new bool UsePhishingBlocker { get; set; }
[Display(Name = "Admin Sponsored Families")]
public bool UseAdminSponsoredFamilies { get; set; }
[Display(Name = "Self Host")]
@@ -193,6 +197,8 @@ public class OrganizationEditModel : OrganizationViewModel
public int? MaxAutoscaleSmServiceAccounts { get; set; }
[Display(Name = "Use Organization Domains")]
public bool UseOrganizationDomains { get; set; }
[Display(Name = "Disable SM Ads For Users")]
public new bool UseDisableSmAdsForUsers { get; set; }
[Display(Name = "Automatic User Confirmation")]
public bool UseAutomaticUserConfirmation { get; set; }
@@ -327,6 +333,8 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.UseOrganizationDomains = UseOrganizationDomains;
existingOrganization.UseDisableSmAdsForUsers = UseDisableSmAdsForUsers;
existingOrganization.UsePhishingBlocker = UsePhishingBlocker;
return existingOrganization;
}
}

View File

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

View File

@@ -156,6 +156,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UsePhishingBlocker" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UsePhishingBlocker"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">
@@ -181,6 +185,13 @@
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseSecretsManager"></label>
</div>
@if (FeatureService.IsEnabled(FeatureFlagKeys.SM1719_RemoveSecretsManagerAds))
{
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseDisableSmAdsForUsers" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseDisableSmAdsForUsers"></label>
</div>
}
</div>
<div class="col-2">
<h3>Access Intelligence</h3>

View File

@@ -8,6 +8,7 @@ using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Organizations.Queries;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Platform.Installations;
using Bit.Core.Repositories;

View File

@@ -5,6 +5,7 @@ using Bit.Admin.Models;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core.Auth.UserFeatures.TwoFactorAuth.Interfaces;
using Bit.Core.Billing.Services;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
@@ -20,7 +21,7 @@ public class UsersController : Controller
{
private readonly IUserRepository _userRepository;
private readonly ICipherRepository _cipherRepository;
private readonly IPaymentService _paymentService;
private readonly IStripePaymentService _paymentService;
private readonly GlobalSettings _globalSettings;
private readonly IAccessControlService _accessControlService;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
@@ -30,7 +31,7 @@ public class UsersController : Controller
public UsersController(
IUserRepository userRepository,
ICipherRepository cipherRepository,
IPaymentService paymentService,
IStripePaymentService paymentService,
GlobalSettings globalSettings,
IAccessControlService accessControlService,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,

View File

@@ -1,7 +1,7 @@
###############################################
# Node.js build stage #
###############################################
FROM node:20-alpine3.21 AS node-build
FROM --platform=$BUILDPLATFORM node:20-alpine3.21 AS node-build
WORKDIR /app
COPY src/Admin/package*.json ./

View File

@@ -1,5 +1,5 @@
using Bit.Core.Utilities;
using Microsoft.Data.SqlClient;
using System.Data.Common;
using Bit.Core.Utilities;
namespace Bit.Admin.HostedServices;
@@ -19,7 +19,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
public virtual async Task StartAsync(CancellationToken cancellationToken)
{
// Wait 20 seconds to allow database to come online
await Task.Delay(20000);
await Task.Delay(20000, cancellationToken);
var maxMigrationAttempts = 10;
for (var i = 1; i <= maxMigrationAttempts; i++)
@@ -30,7 +30,7 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
// TODO: Maybe flip a flag somewhere to indicate migration is complete??
break;
}
catch (SqlException e)
catch (DbException e)
{
if (i >= maxMigrationAttempts)
{
@@ -40,8 +40,8 @@ public class DatabaseMigrationHostedService : IHostedService, IDisposable
else
{
_logger.LogError(e,
"Database unavailable for migration. Trying again (attempt #{0})...", i + 1);
await Task.Delay(20000);
"Database unavailable for migration. Trying again (attempt #{AttemptNumber})...", i + 1);
await Task.Delay(20000, cancellationToken);
}
}
}

View File

@@ -16,19 +16,8 @@ public class Program
o.Limits.MaxRequestLineSize = 20_000;
});
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()
.Run();
}

View File

@@ -65,6 +65,7 @@ public class Startup
default:
break;
}
services.AddTestPlayIdTracking(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();
@@ -132,11 +133,8 @@ public class Startup
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();

View File

@@ -20,11 +20,9 @@
}
},
"Logging": {
"IncludeScopes": false,
"LogLevel": {
"Default": "Debug",
"System": "Information",
"Microsoft": "Information"
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
},
@@ -750,9 +750,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"version": "2.9.13",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.13.tgz",
"integrity": "sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -793,9 +793,9 @@
}
},
"node_modules/browserslist": {
"version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"dev": true,
"funding": [
{
@@ -814,11 +814,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.9",
"caniuse-lite": "^1.0.30001746",
"electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3"
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
},
"bin": {
"browserslist": "cli.js"
@@ -835,9 +835,9 @@
"license": "MIT"
},
"node_modules/caniuse-lite": {
"version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"version": "1.0.30001763",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001763.tgz",
"integrity": "sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==",
"dev": true,
"funding": [
{
@@ -989,9 +989,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"dev": true,
"license": "ISC"
},
@@ -1023,9 +1023,9 @@
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
"dev": true,
"license": "MIT"
},
@@ -1419,13 +1419,17 @@
}
},
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
"integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz",
"integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.11.5"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/locate-path": {
@@ -1542,9 +1546,9 @@
"optional": true
},
"node_modules/node-releases": {
"version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"dev": true,
"license": "MIT"
},
@@ -1875,9 +1879,9 @@
"license": "MIT"
},
"node_modules/sass": {
"version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"version": "1.97.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.97.2.tgz",
"integrity": "sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2110,9 +2114,9 @@
}
},
"node_modules/terser-webpack-plugin": {
"version": "5.3.14",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.14.tgz",
"integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==",
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz",
"integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2174,9 +2178,9 @@
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"dev": true,
"funding": [
{
@@ -2226,9 +2230,9 @@
}
},
"node_modules/webpack": {
"version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"version": "5.104.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"license": "MIT",
"peer": true,
@@ -2241,21 +2245,21 @@
"@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3",
"browserslist": "^4.26.3",
"browserslist": "^4.28.1",
"chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1",
"enhanced-resolve": "^5.17.4",
"es-module-lexer": "^2.0.0",
"eslint-scope": "5.1.1",
"events": "^3.2.0",
"glob-to-regexp": "^0.4.1",
"graceful-fs": "^4.2.11",
"json-parse-even-better-errors": "^2.3.1",
"loader-runner": "^4.2.0",
"loader-runner": "^4.3.1",
"mime-types": "^2.1.27",
"neo-async": "^2.6.2",
"schema-utils": "^4.3.3",
"tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11",
"terser-webpack-plugin": "^5.3.16",
"watchpack": "^2.4.4",
"webpack-sources": "^3.3.3"
},

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.93.2",
"sass": "1.97.2",
"sass-loader": "16.0.5",
"webpack": "5.102.1",
"webpack": "5.104.1",
"webpack-cli": "5.1.4"
}
}

View File

@@ -0,0 +1,14 @@
using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Authorization.Requirements;
/// <summary>
/// Requires that the user is a member of the organization.
/// </summary>
public class MemberRequirement : IOrganizationRequirement
{
public Task<bool> AuthorizeAsync(
CurrentContextOrganization? organizationClaims,
Func<Task<bool>> isProviderUserForOrg)
=> Task.FromResult(organizationClaims is not null);
}

View File

@@ -1,130 +0,0 @@
using Bit.Api.AdminConsole.Models.Request.Organizations;
using Bit.Api.AdminConsole.Models.Response.Organizations;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.AdminConsole.Controllers;
[Route("organizations/{organizationId:guid}/integrations/{integrationId:guid}/configurations")]
[Authorize("Application")]
public class OrganizationIntegrationConfigurationController(
ICurrentContext currentContext,
IOrganizationIntegrationRepository integrationRepository,
IOrganizationIntegrationConfigurationRepository integrationConfigurationRepository) : Controller
{
[HttpGet("")]
public async Task<List<OrganizationIntegrationConfigurationResponseModel>> GetAsync(
Guid organizationId,
Guid integrationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configurations = await integrationConfigurationRepository.GetManyByIntegrationAsync(integrationId);
return configurations
.Select(configuration => new OrganizationIntegrationConfigurationResponseModel(configuration))
.ToList();
}
[HttpPost("")]
public async Task<OrganizationIntegrationConfigurationResponseModel> CreateAsync(
Guid organizationId,
Guid integrationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var organizationIntegrationConfiguration = model.ToOrganizationIntegrationConfiguration(integrationId);
var configuration = await integrationConfigurationRepository.CreateAsync(organizationIntegrationConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(configuration);
}
[HttpPut("{configurationId:guid}")]
public async Task<OrganizationIntegrationConfigurationResponseModel> UpdateAsync(
Guid organizationId,
Guid integrationId,
Guid configurationId,
[FromBody] OrganizationIntegrationConfigurationRequestModel model)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
if (!model.IsValidForType(integration.Type))
{
throw new BadRequestException($"Invalid Configuration and/or Template for integration type {integration.Type}");
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
var newConfiguration = model.ToOrganizationIntegrationConfiguration(configuration);
await integrationConfigurationRepository.ReplaceAsync(newConfiguration);
return new OrganizationIntegrationConfigurationResponseModel(newConfiguration);
}
[HttpDelete("{configurationId:guid}")]
public async Task DeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
if (!await HasPermission(organizationId))
{
throw new NotFoundException();
}
var integration = await integrationRepository.GetByIdAsync(integrationId);
if (integration == null || integration.OrganizationId != organizationId)
{
throw new NotFoundException();
}
var configuration = await integrationConfigurationRepository.GetByIdAsync(configurationId);
if (configuration is null || configuration.OrganizationIntegrationId != integrationId)
{
throw new NotFoundException();
}
await integrationConfigurationRepository.DeleteAsync(configuration);
}
[HttpPost("{configurationId:guid}/delete")]
[Obsolete("This endpoint is deprecated. Use DELETE method instead")]
public async Task PostDeleteAsync(Guid organizationId, Guid integrationId, Guid configurationId)
{
await DeleteAsync(organizationId, integrationId, configurationId);
}
private async Task<bool> HasPermission(Guid organizationId)
{
return await currentContext.OrganizationOwner(organizationId);
}
}

View File

@@ -19,6 +19,7 @@ using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimed
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.SelfRevokeUser;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
@@ -41,6 +42,8 @@ using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Requests;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using V1_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v1.IRevokeOrganizationUserCommand;
using V2_RevokeOrganizationUserCommand = Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RevokeUser.v2;
namespace Bit.Api.AdminConsole.Controllers;
@@ -71,12 +74,15 @@ public class OrganizationUsersController : BaseAdminConsoleController
private readonly IFeatureService _featureService;
private readonly IPricingClient _pricingClient;
private readonly IResendOrganizationInviteCommand _resendOrganizationInviteCommand;
private readonly IBulkResendOrganizationInvitesCommand _bulkResendOrganizationInvitesCommand;
private readonly IAutomaticallyConfirmOrganizationUserCommand _automaticallyConfirmOrganizationUserCommand;
private readonly V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand _revokeOrganizationUserCommandVNext;
private readonly IConfirmOrganizationUserCommand _confirmOrganizationUserCommand;
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly V1_RevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
private readonly ISelfRevokeOrganizationUserCommand _selfRevokeOrganizationUserCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -103,10 +109,13 @@ public class OrganizationUsersController : BaseAdminConsoleController
IConfirmOrganizationUserCommand confirmOrganizationUserCommand,
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
V1_RevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IBulkResendOrganizationInvitesCommand bulkResendOrganizationInvitesCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand,
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand)
IAutomaticallyConfirmOrganizationUserCommand automaticallyConfirmOrganizationUserCommand,
V2_RevokeOrganizationUserCommand.IRevokeOrganizationUserCommand revokeOrganizationUserCommandVNext,
ISelfRevokeOrganizationUserCommand selfRevokeOrganizationUserCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -131,12 +140,15 @@ public class OrganizationUsersController : BaseAdminConsoleController
_featureService = featureService;
_pricingClient = pricingClient;
_resendOrganizationInviteCommand = resendOrganizationInviteCommand;
_bulkResendOrganizationInvitesCommand = bulkResendOrganizationInvitesCommand;
_automaticallyConfirmOrganizationUserCommand = automaticallyConfirmOrganizationUserCommand;
_revokeOrganizationUserCommandVNext = revokeOrganizationUserCommandVNext;
_confirmOrganizationUserCommand = confirmOrganizationUserCommand;
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
_selfRevokeOrganizationUserCommand = selfRevokeOrganizationUserCommand;
}
[HttpGet("{id}")]
@@ -273,7 +285,17 @@ public class OrganizationUsersController : BaseAdminConsoleController
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkReinvite(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
var userId = _userService.GetProperUserId(User);
var result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
IEnumerable<Tuple<Core.Entities.OrganizationUser, string>> result;
if (_featureService.IsEnabled(FeatureFlagKeys.IncreaseBulkReinviteLimitForCloud))
{
result = await _bulkResendOrganizationInvitesCommand.BulkResendInvitesAsync(orgId, userId.Value, model.Ids);
}
else
{
result = await _organizationService.ResendInvitesAsync(orgId, userId.Value, model.Ids);
}
return new ListResponseModel<OrganizationUserBulkResponseModel>(
result.Select(t => new OrganizationUserBulkResponseModel(t.Item1.Id, t.Item2)));
}
@@ -483,43 +505,10 @@ public class OrganizationUsersController : BaseAdminConsoleController
}
}
#nullable enable
[HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>]
public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
@@ -650,6 +639,20 @@ public class OrganizationUsersController : BaseAdminConsoleController
await RestoreOrRevokeUserAsync(orgId, id, _revokeOrganizationUserCommand.RevokeUserAsync);
}
[HttpPut("revoke-self")]
[Authorize<MemberRequirement>]
public async Task<IResult> RevokeSelfAsync(Guid orgId)
{
var userId = _userService.GetProperUserId(User);
if (!userId.HasValue)
{
throw new UnauthorizedAccessException();
}
var result = await _selfRevokeOrganizationUserCommand.SelfRevokeUserAsync(orgId, userId.Value);
return Handle(result);
}
[HttpPatch("{id}/revoke")]
[Obsolete("This endpoint is deprecated. Use PUT method instead")]
[Authorize<ManageUsersRequirement>]
@@ -662,7 +665,24 @@ public class OrganizationUsersController : BaseAdminConsoleController
[Authorize<ManageUsersRequirement>]
public async Task<ListResponseModel<OrganizationUserBulkResponseModel>> BulkRevokeAsync(Guid orgId, [FromBody] OrganizationUserBulkRequestModel model)
{
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")]

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