1
0
mirror of https://github.com/bitwarden/server.git synced 2025-04-05 05:00:19 -05:00

Merge branch 'main' into add-docker-arm64-builds

This commit is contained in:
Vince Grassia 2025-03-10 12:38:54 -04:00
commit ed2019de3e
No known key found for this signature in database
GPG Key ID: 9AD7505E8448CC08
2096 changed files with 391560 additions and 40801 deletions

View File

@ -3,11 +3,11 @@
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "6.5.0",
"version": "7.2.0",
"commands": ["swagger"]
},
"dotnet-ef": {
"version": "8.0.2",
"version": "8.0.8",
"commands": ["dotnet-ef"]
}
}

View File

@ -1,5 +1,3 @@
version: '3'
services:
bitwarden_server:
image: mcr.microsoft.com/devcontainers/dotnet:8.0
@ -9,15 +7,17 @@ services:
command: sleep infinity
bitwarden_mssql:
image: mcr.microsoft.com/azure-sql-edge:latest
image: mcr.microsoft.com/mssql/server:2022-latest
platform: linux/amd64
restart: unless-stopped
env_file:
../../dev/.env
- path: ../../dev/.env
required: false
environment:
ACCEPT_EULA: "Y"
MSSQL_PID: Developer
volumes:
- edgesql_dev_data:/var/opt/mssql
- mssql_dev_data:/var/opt/mssql
- ../../util/Migrator:/mnt/migrator/
- ../../dev/helpers/mssql:/mnt/helpers
- ../../dev/.data/mssql:/mnt/data
@ -29,4 +29,4 @@ services:
network_mode: service:bitwarden_server
volumes:
edgesql_dev_data:
mssql_dev_data:

View File

@ -3,10 +3,21 @@
"dockerComposeFile": "../../.devcontainer/bitwarden_common/docker-compose.yml",
"service": "bitwarden_server",
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
},
"mounts": [
{
"source": "../../dev/.data/keys",
"target": "/home/vscode/.aspnet/DataProtection-Keys",
"type": "bind"
}
],
"customizations": {
"vscode": {
"settings": {},
"features": {},
"extensions": ["ms-dotnettools.csdevkit"]
}
},

View File

@ -51,4 +51,10 @@ Proceed? [y/N] " response
}
# main
one_time_setup
if [[ -z "${CODESPACES}" ]]; then
one_time_setup
else
# Ignore interactive elements when running in codespaces since they are not supported there
# TODO Write codespaces specific instructions and link here
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
fi

View File

@ -3,20 +3,57 @@
"dockerComposeFile": [
"../../.devcontainer/bitwarden_common/docker-compose.yml",
"../../.devcontainer/internal_dev/docker-compose.override.yml"
], "service": "bitwarden_server",
],
"service": "bitwarden_server",
"workspaceFolder": "/workspace",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
}
},
"mounts": [
{
"source": "../../dev/.data/keys",
"target": "/home/vscode/.aspnet/DataProtection-Keys",
"type": "bind"
}
],
"customizations": {
"vscode": {
"settings": {},
"features": {},
"extensions": ["ms-dotnettools.csdevkit"]
}
},
"postCreateCommand": "bash .devcontainer/internal_dev/postCreateCommand.sh",
"forwardPorts": [1080, 1433, 3306, 5432, 10000, 10001, 10002],
"portsAttributes": {
"1080": {
"label": "Mail Catcher",
"onAutoForward": "notify"
},
"1433": {
"label": "SQL Server",
"onAutoForward": "notify"
},
"3306": {
"label": "MySQL",
"onAutoForward": "notify"
},
"5432": {
"label": "PostgreSQL",
"onAutoForward": "notify"
},
"10000": {
"label": "Azurite Storage Blob",
"onAutoForward": "notify"
},
"10001": {
"label": "Azurite Storage Queue ",
"onAutoForward": "notify"
},
"10002": {
"label": "Azurite Storage Table",
"onAutoForward": "notify"
}
}
}

View File

@ -1,5 +1,3 @@
version: '3'
services:
bitwarden_storage:
image: mcr.microsoft.com/azure-storage/azurite:latest

View File

@ -70,7 +70,29 @@ Press <Enter> to continue."
sleep 5 # wait for DB container to start
dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING"
fi
read -r -p "Would you like to install the Stripe CLI? [y/N] " stripe_response
if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
install_stripe_cli
fi
}
# Install Stripe CLI
install_stripe_cli() {
echo "Installing Stripe CLI..."
# Add Stripe CLI GPG key so that apt can verify the packages authenticity.
# If Stripe ever changes the key, we'll need to update this. Visit https://docs.stripe.com/stripe-cli?install-method=apt if so
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg >/dev/null
# Add Stripe CLI repository to apt sources
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list >/dev/null
sudo apt update
sudo apt install -y stripe
}
# main
one_time_setup
if [[ -z "${CODESPACES}" ]]; then
one_time_setup
else
# Ignore interactive elements when running in codespaces since they are not supported there
# TODO Write codespaces specific instructions and link here
echo "Running in codespaces, follow instructions here: https://contributing.bitwarden.com/getting-started/server/guide/ to continue the setup"
fi

34
.github/CODEOWNERS vendored
View File

@ -4,13 +4,18 @@
#
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
# DevOps for Actions and other workflow changes
.github/workflows @bitwarden/dept-devops
## Docker files have shared ownership ##
**/Dockerfile
**/*.Dockerfile
**/.dockerignore
**/entrypoint.sh
# DevOps for Docker changes
**/Dockerfile @bitwarden/dept-devops
**/*.Dockerfile @bitwarden/dept-devops
**/.dockerignore @bitwarden/dept-devops
## BRE team owns these workflows ##
.github/workflows/publish.yml @bitwarden/dept-bre
## These are shared workflows ##
.github/workflows/_move_finalization_db_scripts.yml
.github/workflows/release.yml
# Database Operations for database changes
src/Sql/** @bitwarden/dept-dbops
@ -26,7 +31,9 @@ util/SqliteMigrations/** @bitwarden/dept-dbops
bitwarden_license/src/Sso @bitwarden/team-auth-dev
src/Identity @bitwarden/team-auth-dev
**/SecretsManager @bitwarden/team-secrets-manager-dev
# Key Management team
**/KeyManagement @bitwarden/team-key-management-dev
**/Tools @bitwarden/team-tools-dev
# Vault team
@ -38,6 +45,8 @@ src/Identity @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
@ -55,6 +64,15 @@ bitwarden_license/src/test/Scim.ScimTest @bitwarden/team-admin-console-dev
src/Admin/Controllers/ToolsController.cs @bitwarden/team-billing-dev
src/Admin/Views/Tools @bitwarden/team-billing-dev
# Multiple owners - DO NOT REMOVE (DevOps)
# Platform team
.github/workflows/build.yml @bitwarden/team-platform-dev
.github/workflows/cleanup-after-pr.yml @bitwarden/team-platform-dev
.github/workflows/cleanup-rc-branch.yml @bitwarden/team-platform-dev
.github/workflows/repository-management.yml @bitwarden/team-platform-dev
.github/workflows/test-database.yml @bitwarden/team-platform-dev
.github/workflows/test.yml @bitwarden/team-platform-dev
**/*Platform* @bitwarden/team-platform-dev
# Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json
Directory.Build.props

View File

@ -1,30 +1,35 @@
## Type of change
## 🎟️ Tracking
<!-- (mark with an `X`) -->
<!-- Paste the link to the Jira or GitHub issue or otherwise describe / point to where this change is coming from. -->
```
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other
```
## 📔 Objective
## Objective
<!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding-->
<!-- Describe what the purpose of this PR is, for example what bug you're fixing or new feature you're adding. -->
## 📸 Screenshots
<!-- Required for any UI changes; delete if not applicable. Use fixed width images for better display. -->
## Code changes
<!--Explain the changes you've made to each file or major component. This should help the reviewer understand your changes-->
<!--Also refer to any related changes or PRs in other repositories-->
## ⏰ Reminders before review
* **file.ext:** Description of what was changed and why
- Contributor guidelines followed
- All formatters and local linters executed and passed
- Written new unit and / or integration tests where applicable
- Protected functional changes with optionality (feature flags)
- Used internationalization (i18n) for all UI strings
- CI builds passed
- Communicated to DevOps any deployment requirements
- Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team
## Before you submit
## 🦮 Reviewer guidelines
- Please check for formatting errors (`dotnet format --verify-no-changes`) (required)
- If making database changes - make sure you also update Entity Framework queries and/or migrations
- Please add **unit tests** where it makes sense to do so (encouraged but not required)
- If this change requires a **documentation update** - notify the documentation team
- If this change has particular **deployment requirements** - notify the DevOps team
<!-- Suggested interactions but feel free to use (or not) as you desire! -->
- 👍 (`:+1:`) or similar for great changes
- 📝 (`:memo:`) or (`:information_source:`) for notes or general info
- ❓ (`:question:`) for questions
- 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion
- 🎨 (`:art:`) for suggestions / improvements
- ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention
- 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt
- ⛏ (`:pick:`) for minor or nitpick changes

202
.github/renovate.json vendored
View File

@ -1,202 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["github>bitwarden/renovate-config"],
"enabledManagers": [
"dockerfile",
"docker-compose",
"github-actions",
"npm",
"nuget"
],
"packageRules": [
{
"groupName": "dockerfile minor",
"matchManagers": ["dockerfile"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"groupName": "docker-compose minor",
"matchManagers": ["docker-compose"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"groupName": "gh minor",
"matchManagers": ["github-actions"],
"matchUpdateTypes": ["minor", "patch"]
},
{
"matchManagers": ["github-actions", "dockerfile", "docker-compose"],
"commitMessagePrefix": "[deps] DevOps:"
},
{
"matchPackageNames": ["DnsClient", "Quartz"],
"description": "Admin Console owned dependencies",
"commitMessagePrefix": "[deps] AC:",
"reviewers": ["team:team-admin-console-dev"]
},
{
"matchFileNames": ["src/Admin/package.json", "src/Sso/package.json"],
"description": "Admin & SSO npm packages",
"commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"]
},
{
"matchPackageNames": ["bootstrap", "del", "gulp"],
"matchUpdateTypes": ["major"],
"description": "Lock bootstrap, del, and gulp major versions due to ASP.NET conflicts",
"enabled": false
},
{
"matchPackageNames": [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Azure.Cosmos",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
"YubicoDotNetClient"
],
"description": "Auth owned dependencies",
"commitMessagePrefix": "[deps] Auth:",
"reviewers": ["team:team-auth-dev"]
},
{
"matchPackageNames": [
"AutoFixture.AutoNSubstitute",
"AutoFixture.Xunit2",
"BenchmarkDotNet",
"BitPay.Light",
"Braintree",
"coverlet.collector",
"FluentAssertions",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
"xunit",
"xunit.runner.visualstudio"
],
"description": "Billing owned dependencies",
"commitMessagePrefix": "[deps] Billing:",
"reviewers": ["team:team-billing-dev"]
},
{
"matchPackagePatterns": ["^Microsoft.Extensions.Logging"],
"groupName": "Microsoft.Extensions.Logging",
"description": "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset"
},
{
"matchPackageNames": [
"Dapper",
"dbup-sqlserver",
"dotnet-ef",
"linq2db.EntityFrameworkCore",
"Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory",
"Microsoft.EntityFrameworkCore.Relational",
"Microsoft.EntityFrameworkCore.Sqlite",
"Microsoft.EntityFrameworkCore.SqlServer",
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql"
],
"description": "DbOps owned dependencies",
"commitMessagePrefix": "[deps] DbOps:",
"reviewers": ["team:dept-dbops"]
},
{
"matchPackageNames": ["CommandDotNet", "YamlDotNet"],
"description": "DevOps owned dependencies",
"commitMessagePrefix": "[deps] DevOps:",
"reviewers": ["team:dept-devops"]
},
{
"matchPackageNames": [
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http"
],
"description": "Platform owned dependencies",
"commitMessagePrefix": "[deps] Platform:",
"reviewers": ["team:team-platform-dev"]
},
{
"matchPackagePatterns": ["EntityFrameworkCore", "^dotnet-ef"],
"groupName": "EntityFrameworkCore",
"description": "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset"
},
{
"matchPackageNames": [
"AutoMapper.Extensions.Microsoft.DependencyInjection",
"AWSSDK.SimpleEmail",
"AWSSDK.SQS",
"Handlebars.Net",
"LaunchDarkly.ServerSdk",
"MailKit",
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
"Microsoft.Azure.NotificationHubs",
"Microsoft.Extensions.Configuration.EnvironmentVariables",
"Microsoft.Extensions.Configuration.UserSecrets",
"Microsoft.Extensions.Configuration",
"Microsoft.Extensions.DependencyInjection.Abstractions",
"Microsoft.Extensions.DependencyInjection",
"SendGrid"
],
"description": "Tools owned dependencies",
"commitMessagePrefix": "[deps] Tools:",
"reviewers": ["team:team-tools-dev"]
},
{
"matchPackagePatterns": ["^Microsoft.AspNetCore.SignalR"],
"groupName": "SignalR",
"description": "Group SignalR to exclude them from the dotnet monorepo preset"
},
{
"matchPackagePatterns": ["^Microsoft.Extensions.Configuration"],
"groupName": "Microsoft.Extensions.Configuration",
"description": "Group Microsoft.Extensions.Configuration to exclude them from the dotnet monorepo preset"
},
{
"matchPackagePatterns": ["^Microsoft.Extensions.DependencyInjection"],
"groupName": "Microsoft.Extensions.DependencyInjection",
"description": "Group Microsoft.Extensions.DependencyInjection to exclude them from the dotnet monorepo preset"
},
{
"matchPackageNames": [
"AngleSharp",
"AspNetCore.HealthChecks.AzureServiceBus",
"AspNetCore.HealthChecks.AzureStorage",
"AspNetCore.HealthChecks.Network",
"AspNetCore.HealthChecks.Redis",
"AspNetCore.HealthChecks.SendGrid",
"AspNetCore.HealthChecks.SqlServer",
"AspNetCore.HealthChecks.Uris"
],
"description": "Vault owned dependencies",
"commitMessagePrefix": "[deps] Vault:",
"reviewers": ["team:team-vault-dev"]
}
],
"ignoreDeps": ["dotnet-sdk"]
}

199
.github/renovate.json5 vendored Normal file
View File

@ -0,0 +1,199 @@
{
$schema: "https://docs.renovatebot.com/renovate-schema.json",
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
enabledManagers: [
"dockerfile",
"docker-compose",
"github-actions",
"npm",
"nuget",
],
packageRules: [
{
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"],
},
{
matchManagers: ["dockerfile", "docker-compose"],
commitMessagePrefix: "[deps] BRE:",
},
{
matchPackageNames: ["DnsClient"],
description: "Admin Console owned dependencies",
commitMessagePrefix: "[deps] AC:",
reviewers: ["team:team-admin-console-dev"],
},
{
matchFileNames: ["src/Admin/package.json", "src/Sso/package.json"],
description: "Admin & SSO npm packages",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
"DuoUniversal",
"Fido2.AspNet",
"Duende.IdentityServer",
"Microsoft.Extensions.Identity.Stores",
"Otp.NET",
"Sustainsys.Saml2.AspNetCore2",
"YubicoDotNetClient",
],
description: "Auth owned dependencies",
commitMessagePrefix: "[deps] Auth:",
reviewers: ["team:team-auth-dev"],
},
{
matchPackageNames: [
"AutoFixture.AutoNSubstitute",
"AutoFixture.Xunit2",
"BenchmarkDotNet",
"BitPay.Light",
"Braintree",
"coverlet.collector",
"CsvHelper",
"Kralizek.AutoFixture.Extensions.MockHttp",
"Microsoft.AspNetCore.Mvc.Testing",
"Microsoft.Extensions.Logging",
"Microsoft.Extensions.Logging.Console",
"Newtonsoft.Json",
"NSubstitute",
"Sentry.Serilog",
"Serilog.AspNetCore",
"Serilog.Extensions.Logging",
"Serilog.Extensions.Logging.File",
"Serilog.Sinks.AzureCosmosDB",
"Serilog.Sinks.SyslogMessages",
"Stripe.net",
"Swashbuckle.AspNetCore",
"Swashbuckle.AspNetCore.SwaggerGen",
"xunit",
"xunit.runner.visualstudio",
],
description: "Billing owned dependencies",
commitMessagePrefix: "[deps] Billing:",
reviewers: ["team:team-billing-dev"],
},
{
matchPackagePatterns: ["^Microsoft.Extensions.Logging"],
groupName: "Microsoft.Extensions.Logging",
description: "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"Dapper",
"dbup-sqlserver",
"dotnet-ef",
"linq2db.EntityFrameworkCore",
"Microsoft.Azure.Cosmos",
"Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory",
"Microsoft.EntityFrameworkCore.Relational",
"Microsoft.EntityFrameworkCore.Sqlite",
"Microsoft.EntityFrameworkCore.SqlServer",
"Microsoft.Extensions.Caching.Cosmos",
"Microsoft.Extensions.Caching.SqlServer",
"Microsoft.Extensions.Caching.StackExchangeRedis",
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql",
],
description: "DbOps owned dependencies",
commitMessagePrefix: "[deps] DbOps:",
reviewers: ["team:dept-dbops"],
},
{
matchPackageNames: ["CommandDotNet", "YamlDotNet"],
description: "DevOps owned dependencies",
commitMessagePrefix: "[deps] BRE:",
reviewers: ["team:dept-bre"],
},
{
matchPackageNames: [
"AspNetCoreRateLimit",
"AspNetCoreRateLimit.Redis",
"Azure.Data.Tables",
"Azure.Messaging.EventGrid",
"Azure.Messaging.ServiceBus",
"Azure.Storage.Blobs",
"Azure.Storage.Queues",
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http",
"Quartz",
],
description: "Platform owned dependencies",
commitMessagePrefix: "[deps] Platform:",
reviewers: ["team:team-platform-dev"],
},
{
matchPackagePatterns: ["EntityFrameworkCore", "^dotnet-ef"],
groupName: "EntityFrameworkCore",
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"AutoMapper.Extensions.Microsoft.DependencyInjection",
"AWSSDK.SimpleEmail",
"AWSSDK.SQS",
"Handlebars.Net",
"LaunchDarkly.ServerSdk",
"MailKit",
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
"Microsoft.Azure.NotificationHubs",
"Microsoft.Extensions.Configuration.EnvironmentVariables",
"Microsoft.Extensions.Configuration.UserSecrets",
"Microsoft.Extensions.Configuration",
"Microsoft.Extensions.DependencyInjection.Abstractions",
"Microsoft.Extensions.DependencyInjection",
"SendGrid",
],
description: "Tools owned dependencies",
commitMessagePrefix: "[deps] Tools:",
reviewers: ["team:team-tools-dev"],
},
{
matchPackagePatterns: ["^Microsoft.AspNetCore.SignalR"],
groupName: "SignalR",
description: "Group SignalR to exclude them from the dotnet monorepo preset",
},
{
matchPackagePatterns: ["^Microsoft.Extensions.Configuration"],
groupName: "Microsoft.Extensions.Configuration",
description: "Group Microsoft.Extensions.Configuration to exclude them from the dotnet monorepo preset",
},
{
matchPackagePatterns: ["^Microsoft.Extensions.DependencyInjection"],
groupName: "Microsoft.Extensions.DependencyInjection",
description: "Group Microsoft.Extensions.DependencyInjection to exclude them from the dotnet monorepo preset",
},
{
matchPackageNames: [
"AngleSharp",
"AspNetCore.HealthChecks.AzureServiceBus",
"AspNetCore.HealthChecks.AzureStorage",
"AspNetCore.HealthChecks.Network",
"AspNetCore.HealthChecks.Redis",
"AspNetCore.HealthChecks.SendGrid",
"AspNetCore.HealthChecks.SqlServer",
"AspNetCore.HealthChecks.Uris",
],
description: "Vault owned dependencies",
commitMessagePrefix: "[deps] Vault:",
reviewers: ["team:team-vault-dev"],
},
],
ignoreDeps: ["dotnet-sdk"],
}

View File

@ -1,4 +1,3 @@
---
name: _move_finalization_db_scripts
run-name: Move finalization database scripts
@ -18,7 +17,7 @@ jobs:
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
steps:
- name: Log in to Azure
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -30,7 +29,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -54,7 +53,7 @@ jobs:
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
@ -94,7 +93,7 @@ jobs:
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
- name: Log in to Azure - production subscription
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -108,7 +107,7 @@ jobs:
devops-alerts-slack-webhook-url"
- name: Import GPG keys
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
uses: crazy-max/ghaction-import-gpg@cb9bde2e2525e640591a934b1fd28eef1dcaf5e5 # v6.2.0
with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
@ -154,7 +153,7 @@ jobs:
- name: Notify Slack about creation of PR
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:

View File

@ -1,4 +1,3 @@
---
name: Automatic responses
on:
issues:
@ -14,7 +13,7 @@ jobs:
# Feature request
- if: github.event.label.name == 'feature-request'
name: Feature request
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
We use GitHub issues as a place to track bugs and other development related issues. The [Bitwarden Community Forums](https://community.bitwarden.com/) has a [Feature Requests](https://community.bitwarden.com/c/feature-requests) section for submitting, voting for, and discussing requests like this one.
@ -25,7 +24,7 @@ jobs:
# Intended behavior
- if: github.event.label.name == 'intended-behavior'
name: Intended behavior
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
Your issue appears to be describing the intended behavior of the software. If you want this to be changed, it would be a feature request.
@ -38,7 +37,7 @@ jobs:
# Customer support request
- if: github.event.label.name == 'customer-support'
name: Customer Support request
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
We use GitHub issues as a place to track bugs and other development related issues. Your issue appears to be a support request, or would otherwise be better handled by our dedicated Customer Success team.
@ -49,14 +48,14 @@ jobs:
# Resolved
- if: github.event.label.name == 'resolved'
name: Resolved
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
Weve closed this issue, as it appears the original problem has been resolved. If this happens again or continues to be an problem, please respond to this issue with any additional detail to assist with reproduction and root cause analysis.
# Stale
- if: github.event.label.name == 'stale'
name: Stale
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
uses: peter-evans/close-issue@276d7966e389d888f011539a86c8920025ea0626 # v3.0.1
with:
comment: |
As we havent heard from you about this problem in some time, this issue will now be closed.

View File

@ -1,4 +1,3 @@
---
name: Build
on:
@ -8,39 +7,131 @@ on:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
pull_request_target:
types: [opened, synchronize]
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
check-run:
name: Check PR run
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
lint:
name: Lint
runs-on: ubuntu-22.04
needs: check-run
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Install just
uses: taiki-e/install-action@4fedbddde88aab767a45a011661f832d68202716 # v2.33.28
- name: Verify format
run: dotnet format --verify-no-changes
build-artifacts:
name: Build artifacts
runs-on: ubuntu-22.04
needs: lint
strategy:
fail-fast: false
matrix:
include:
- project_name: Admin
base_path: ./src
node: true
- project_name: Api
base_path: ./src
- project_name: Billing
base_path: ./src
- project_name: Events
base_path: ./src
- project_name: EventsProcessor
base_path: ./src
- project_name: Icons
base_path: ./src
- project_name: Identity
base_path: ./src
- project_name: MsSqlMigratorUtility
base_path: ./util
dotnet: true
- project_name: Notifications
base_path: ./src
- project_name: Scim
base_path: ./bitwarden_license/src
dotnet: true
- project_name: Server
base_path: ./util
- project_name: Setup
base_path: ./util
- project_name: Sso
base_path: ./bitwarden_license/src
node: true
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
tool: just
ref: ${{ github.event.pull_request.head.sha }}
- name: Lint
run: just lint
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Set up Node
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
cache: "npm"
cache-dependency-path: "**/package-lock.json"
node-version: "16"
- name: Print environment
run: |
whoami
dotnet --info
node --version
npm --version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Build node
if: ${{ matrix.node }}
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
run: |
npm ci
npm run build
- name: Publish project
working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
run: |
echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish
cd obj/build-output/publish
zip -r ${{ matrix.project_name }}.zip .
mv ${{ matrix.project_name }}.zip ../../../
pwd
ls -atlh ../../../
- name: Upload project artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
if-no-files-found: error
build-docker:
name: Build Docker images
runs-on: ubuntu-22.04
needs:
- build-artifacts
permissions:
id-token: write
security-events: write
needs: lint
env:
PROJECT_NAME: ${{ matrix.project_name }}
BASE_PATH: ${{ matrix.base_path}}
strategy:
fail-fast: false
matrix:
@ -88,13 +179,10 @@ jobs:
base_path: ./bitwarden_license/src
upload_artifact: true
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Install just
uses: taiki-e/install-action@4fedbddde88aab767a45a011661f832d68202716 # v2.33.28
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
tool: just
ref: ${{ github.event.pull_request.head.sha }}
- name: Check branch to publish
env:
@ -109,6 +197,27 @@ jobs:
echo "is_publish_branch=false" >> $GITHUB_ENV
fi
########## ACRs ##########
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Log in to ACR - production subscription
run: az acr login -n bitwardenprod
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- 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"
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@68827325e0b33c7199eb31dd4e31fbe9023e06e3 # v3.0.0
@ -116,45 +225,23 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
########## ACRs ##########
- name: Login to Azure - PROD Subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
run: |
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
fi
# - name: Login to PROD ACR
# run: az acr login -n ${_AZ_REGISTRY%.azurecr.io}
# ########## Generate image tag and build Docker image ##########
# - name: Generate Docker image tag
# id: tag
# run: |
# if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
# IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
# else
# IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
# fi
# if [[ "$IMAGE_TAG" == "main" ]]; then
# IMAGE_TAG=dev
# fi
# echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY
# - name: Set up project name
# id: setup
# run: |
# PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
# echo "Matrix name: ${{ matrix.project_name }}"
# echo "PROJECT_NAME: $PROJECT_NAME"
# echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
- name: Justfile
run: just build
- name: TEST FAIL
run: exit 1
- name: Set up project name
id: setup
run: |
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
- name: Generate image tags(s)
id: image-tags
@ -171,17 +258,25 @@ jobs:
fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT
- name: Generate image full name
id: cache-name
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: echo "name=${_AZ_REGISTRY}/${PROJECT_NAME}:buildcache" >> $GITHUB_OUTPUT
- name: Get build artifact
if: ${{ matrix.dotnet }}
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
with:
name: ${{ matrix.project_name }}.zip
- name: Set up build artifact
if: ${{ matrix.dotnet }}
run: |
mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish
unzip ${{ matrix.project_name }}.zip \
-d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker image
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
id: build-docker
uses: docker/build-push-action@67a2d409c0a876cbe6b11854e3e25193efe4e62d # v6.12.0
with:
cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }}
cache-to: type=registry,ref=${{ steps.cache-name.outputs.name}},mode=max
# cache-from: type=registry,ref=${{ steps.cache-name.outputs.name }}
# cache-to: type=registry,ref=${{ steps.cache-name.outputs.name}},mode=max
context: .
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
platforms: |
@ -191,16 +286,33 @@ jobs:
push: true
tags: ${{ steps.image-tags.outputs.tags }}
- name: Install Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Sign image with Cosign
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
env:
DIGEST: ${{ steps.build-docker.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }}
run: |
IFS="," read -a tags <<< "${TAGS}"
images=""
for tag in "${tags[@]}"; do
images+="${tag}@${DIGEST} "
done
cosign sign --yes ${images}
- name: Scan Docker image
id: container-scan
uses: anchore/scan-action@3343887d815d7b07465f6fdcd395bd66508d486a # v3.6.4
uses: anchore/scan-action@abae793926ec39a78ab18002bc7fc45bbbd94342 # v6.0.0
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@b7bf0a3ed3ecfa44160715d7c442788f65f0f923 # v3.23.2
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: ${{ steps.container-scan.outputs.sarif }}
@ -209,14 +321,16 @@ jobs:
runs-on: ubuntu-22.04
needs: build-docker
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -227,12 +341,12 @@ jobs:
run: dotnet tool restore
- name: Make Docker stubs
if: github.ref == 'refs/heads/main' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc'
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: |
# Set proper setup image based on branch
case "${{ github.ref }}" in
case "$GITHUB_REF" in
"refs/heads/main")
SETUP_IMAGE="$_AZ_REGISTRY/setup:dev"
;;
@ -269,44 +383,54 @@ jobs:
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
- name: Make Docker stub checksums
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
run: |
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
- name: Upload Docker stub US artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-US.zip
path: docker-stub-US.zip
if-no-files-found: error
- name: Upload Docker stub EU artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-EU.zip
path: docker-stub-EU.zip
if-no-files-found: error
- name: Upload Docker stub US checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-US-sha256.txt
path: docker-stub-US-sha256.txt
if-no-files-found: error
- name: Upload Docker stub EU checksum artifact
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: docker-stub-EU-sha256.txt
path: docker-stub-EU-sha256.txt
if-no-files-found: error
- name: Build Swagger
- name: Build Public API Swagger
run: |
cd ./src/Api
echo "Restore"
@ -325,17 +449,58 @@ jobs:
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Swagger artifact
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
- name: Upload Public API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: swagger.json
path: swagger.json
if-no-files-found: error
- name: Build Internal API Swagger
run: |
cd ./src/Api
echo "Restore API tools"
dotnet tool restore
echo "Publish API"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../internal.json --host https://api.bitwarden.com \
./obj/build-output/publish/Api.dll internal
cd ../Identity
echo "Restore Identity tools"
dotnet tool restore
echo "Publish Identity"
dotnet publish -c "Release" -o obj/build-output/publish
dotnet swagger tofile --output ../../identity.json --host https://identity.bitwarden.com \
./obj/build-output/publish/Identity.dll v1
cd ../..
env:
ASPNETCORE_ENVIRONMENT: Development
swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Internal API Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: internal.json
path: internal.json
if-no-files-found: error
- name: Upload Identity Swagger artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: identity.json
path: identity.json
if-no-files-found: error
build-mssqlmigratorutility:
name: Build MSSQL migrator utility
runs-on: ubuntu-22.04
needs: build-docker
needs: lint
defaults:
run:
shell: bash
@ -348,11 +513,13 @@ jobs:
- linux-x64
- win-x64
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment
run: |
@ -373,7 +540,7 @@ jobs:
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
@ -381,7 +548,7 @@ jobs:
- name: Upload project artifact
if: ${{ contains(matrix.target, 'win') == false }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: MsSqlMigratorUtility-${{ matrix.target }}
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
@ -389,11 +556,15 @@ jobs:
self-host-build:
name: Trigger self-host build
if: |
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
runs-on: ubuntu-22.04
needs: build-docker
needs:
- build-docker
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -405,7 +576,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger self-host build
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@ -415,18 +586,19 @@ jobs:
workflow_id: 'build-unified.yml',
ref: 'main',
inputs: {
server_branch: '${{ github.ref }}'
server_branch: process.env.GITHUB_REF
}
})
});
trigger-k8s-deploy:
name: Trigger k8s deploy
if: github.ref == 'refs/heads/main'
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'
runs-on: ubuntu-22.04
needs: build-docker
needs:
- build-docker
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -438,7 +610,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Trigger k8s deploy
uses: actions/github-script@d7906e4ad0b1822421a7e6a35d5ca353c962f410 # v6.4.1
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
@ -453,6 +625,57 @@ jobs:
}
})
trigger-ee-updates:
name: Trigger Ephemeral Environment updates
if: |
github.event_name == 'pull_request_target'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
runs-on: ubuntu-24.04
needs:
- build-docker
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- 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"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})
trigger-ephemeral-environment-sync:
name: Trigger Ephemeral Environment Sync
needs: trigger-ee-updates
if: |
github.event_name == 'pull_request_target'
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
with:
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
project: server
sync_environment: true
pull_request_number: ${{ github.event.number }}
secrets: inherit
check-failures:
name: Check for failures
if: false
@ -466,14 +689,13 @@ jobs:
steps:
- name: Check if any job failed
if: |
(github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc')
github.event_name != 'pull_request_target'
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
if: failure()
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -487,7 +709,7 @@ jobs:
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}

View File

@ -1,4 +1,3 @@
---
name: Container registry cleanup
on:
@ -14,7 +13,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}

View File

@ -1,4 +1,3 @@
---
name: Cleanup RC Branch
on:
@ -24,7 +23,7 @@ jobs:
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout main
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}

View File

@ -1,26 +1,43 @@
---
name: Collect code references
on:
pull_request:
branches-ignore:
- "renovate/**"
permissions:
contents: read
pull-requests: write
jobs:
check-ld-secret:
name: Check for LD secret
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-ld-secret.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-ld-secret
run: |
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
refs:
name: Code reference collection
runs-on: ubuntu-22.04
needs: check-ld-secret
if: ${{ needs.check-ld-secret.outputs.available == 'true' }}
permissions:
contents: read
pull-requests: write
steps:
- name: Check out repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Collect
id: collect
uses: launchdarkly/find-code-references-in-pull-request@2e9333c88539377cfbe818c265ba8b9ebced3c91 # v1.1.0
uses: launchdarkly/find-code-references-in-pull-request@30f4c4ab2949bbf258b797ced2fbf6dea34df9ce # v2.1.0
with:
project-key: default
environment-key: dev

View File

@ -1,102 +0,0 @@
---
name: Container registry purge
on:
schedule:
- cron: "0 0 * * SUN"
workflow_dispatch:
inputs: {}
jobs:
purge:
name: Purge old images
runs-on: ubuntu-22.04
steps:
- name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Purge images
env:
REGISTRY: bitwardenprod
AGO_DUR_VER: "180d"
AGO_DUR: "30d"
run: |
REPO_LIST=$(az acr repository list -n $REGISTRY -o tsv)
for REPO in $REPO_LIST
do
PURGE_LATEST=""
PURGE_VERSION=""
PURGE_ELSE=""
TAG_LIST=$(az acr repository show-tags -n $REGISTRY --repository $REPO -o tsv)
for TAG in $TAG_LIST
do
if [ $TAG = "latest" ] || [ $TAG = "dev" ]; then
PURGE_LATEST+="--filter '$REPO:$TAG' "
elif [[ $TAG =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
PURGE_VERSION+="--filter '$REPO:$TAG' "
else
PURGE_ELSE+="--filter '$REPO:$TAG' "
fi
done
if [ ! -z "$PURGE_LATEST" ]
then
PURGE_LATEST_CMD="acr purge $PURGE_LATEST --ago $AGO_DUR_VER --untagged --keep 1"
az acr run --cmd "$PURGE_LATEST_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_VERSION" ]
then
PURGE_VERSION_CMD="acr purge $PURGE_VERSION --ago $AGO_DUR_VER --untagged"
az acr run --cmd "$PURGE_VERSION_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_ELSE" ]
then
PURGE_ELSE_CMD="acr purge $PURGE_ELSE --ago $AGO_DUR --untagged"
az acr run --cmd "$PURGE_ELSE_CMD" --registry $REGISTRY /dev/null &
fi
wait
done
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-22.04
needs: [purge]
steps:
- name: Check if any job failed
if: |
(github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc')
&& contains(needs.*.result, 'failure')
run: exit 1
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
if: failure()
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
if: failure()
with:
keyvault: "bitwarden-ci"
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@ed1309ab9862e57e9e583e51c7889486b9a00b0f # v2.0.0
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@ -1,4 +1,3 @@
---
name: Enforce PR labels
on:
@ -7,13 +6,13 @@ on:
types: [labeled, unlabeled, opened, reopened, synchronize]
jobs:
enforce-label:
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') }}
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') || contains(github.event.*.labels.*.name, 'ephemeral-environment') }}
name: Enforce label
runs-on: ubuntu-22.04
steps:
- name: Check for label
run: |
echo "PRs with the hold or needs-qa labels cannot be merged"
echo "### :x: PRs with the hold or needs-qa labels cannot be merged" >> $GITHUB_STEP_SUMMARY
echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged"
echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY
exit 1

View File

@ -0,0 +1,38 @@
name: Ephemeral Environment
on:
pull_request:
types: [labeled]
jobs:
trigger-ee-updates:
name: Trigger Ephemeral Environment updates
runs-on: ubuntu-24.04
if: github.event.label.name == 'ephemeral-environment'
steps:
- name: Log in to Azure - CI subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- 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"
- name: Trigger Ephemeral Environment update
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
script: |
await github.rest.actions.createWorkflowDispatch({
owner: 'bitwarden',
repo: 'devops',
workflow_id: '_update_ephemeral_tags.yml',
ref: 'main',
inputs: {
ephemeral_env_branch: process.env.GITHUB_HEAD_REF
}
})

View File

@ -1,7 +1,6 @@
# Runs if there are changes to the paths: list.
# Starts a matrix job to check for modified files, then sets output based on the results.
# The input decides if the label job is ran, adding a label to the PR.
---
name: Protect files
on:
@ -29,7 +28,7 @@ jobs:
label: "DB-migrations-changed"
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 2

181
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,181 @@
name: Publish
run-name: Publish ${{ inputs.publish_type }}
on:
workflow_dispatch:
inputs:
publish_type:
description: "Publish Options"
required: true
default: "Initial Publish"
type: choice
options:
- Initial Publish
- Redeploy
- Dry Run
version:
description: 'Version to publish (default: latest release)'
required: true
type: string
default: latest
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
outputs:
branch-name: ${{ steps.branch.outputs.branch-name }}
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
release-version: ${{ steps.version-output.outputs.version }}
steps:
- name: Version output
id: version-output
run: |
if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then
VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+')
echo "Latest Released Version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
else
echo "Release Version: ${{ inputs.version }}"
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Get branch name
id: branch
run: |
BRANCH_NAME=$(basename ${{ github.ref }})
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
- name: Create GitHub deployment
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
id: deployment
with:
token: '${{ secrets.GITHUB_TOKEN }}'
initial-status: 'in_progress'
environment: 'production'
description: 'Deployment ${{ steps.version-output.outputs.release-version }} from branch ${{ github.ref_name }}'
task: release
publish-docker:
name: Publish Docker images
runs-on: ubuntu-22.04
needs: setup
env:
_RELEASE_VERSION: ${{ needs.setup.outputs.release-version }}
_BRANCH_NAME: ${{ needs.setup.outputs.branch-name }}
strategy:
fail-fast: false
matrix:
include:
- project_name: Admin
- project_name: Api
- project_name: Attachments
- project_name: Billing
- project_name: Events
- project_name: EventsProcessor
- project_name: Icons
- project_name: Identity
- project_name: MsSql
- project_name: MsSqlMigratorUtility
- project_name: Nginx
- project_name: Notifications
- project_name: Scim
- project_name: Server
- project_name: Setup
- project_name: Sso
steps:
- name: Print environment
env:
RELEASE_OPTION: ${{ inputs.publish_type }}
run: |
whoami
docker --version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up project name
id: setup
run: |
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
########## ACR PROD ##########
- name: Log in to Azure - production subscription
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Pull latest project image
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
else
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
fi
- name: Tag version and latest
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest
fi
- name: Push version and latest image
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest
fi
- name: Log out of Docker
run: docker logout
update-deployment:
name: Update Deployment Status
runs-on: ubuntu-22.04
needs:
- setup
- publish-docker
if: ${{ always() && inputs.publish_type != 'Dry Run' }}
steps:
- name: Check if any job failed
if: contains(needs.*.result, 'failure')
run: exit 1
- name: Update deployment status to Success
if: ${{ inputs.publish_type != 'Dry Run' && success() }}
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'success'
deployment-id: ${{ needs.setup.outputs.deployment-id }}
- name: Update deployment status to Failure
if: ${{ inputs.publish_type != 'Dry Run' && failure() }}
uses: chrnorm/deployment-status@9a72af4586197112e0491ea843682b5dc280d806 # v2.0.3
with:
token: '${{ secrets.GITHUB_TOKEN }}'
state: 'failure'
deployment-id: ${{ needs.setup.outputs.deployment-id }}

View File

@ -1,4 +1,3 @@
---
name: Release
run-name: Release ${{ inputs.release_type }}
@ -27,7 +26,7 @@ jobs:
branch-name: ${{ steps.branch.outputs.branch-name }}
steps:
- name: Branch check
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
if: ${{ inputs.release_type != 'Dry Run' }}
run: |
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
echo "==================================="
@ -37,13 +36,13 @@ jobs:
fi
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check release version
id: version
uses: bitwarden/gh-actions/release-version-check@main
with:
release-type: ${{ github.event.inputs.release_type }}
release-type: ${{ inputs.release_type }}
project-type: dotnet
file: Directory.Build.props
@ -53,227 +52,13 @@ jobs:
BRANCH_NAME=$(basename ${{ github.ref }})
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
deploy:
name: Deploy
runs-on: ubuntu-22.04
needs: setup
strategy:
fail-fast: false
matrix:
include:
- name: Admin
- name: Api
- name: Billing
- name: Events
- name: Identity
- name: Sso
steps:
- name: Setup
id: setup
run: |
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.name }}"
echo "NAME_LOWER: $NAME_LOWER"
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
- name: Create GitHub deployment for ${{ matrix.name }}
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: chrnorm/deployment-action@d42cde7132fcec920de534fffc3be83794335c00 # v2.0.5
id: deployment
with:
token: "${{ secrets.GITHUB_TOKEN }}"
initial-status: "in_progress"
environment: "Production Cloud"
task: "deploy"
description: "Deploy from ${{ needs.setup.outputs.branch-name }} branch"
- name: Download latest release ${{ matrix.name }} asset
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build.yml
workflow_conclusion: success
branch: ${{ needs.setup.outputs.branch-name }}
artifacts: ${{ matrix.name }}.zip
- name: Dry run - Download latest release ${{ matrix.name }} asset
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build.yml
workflow_conclusion: success
branch: main
artifacts: ${{ matrix.name }}.zip
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
env:
VAULT_NAME: "bitwarden-ci"
run: |
webapp_name=$(
az keyvault secret show --vault-name $VAULT_NAME \
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
--query value --output tsv
)
publish_profile=$(
az keyvault secret show --vault-name $VAULT_NAME \
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-publish-profile \
--query value --output tsv
)
echo "::add-mask::$webapp_name"
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
echo "::add-mask::$publish_profile"
echo "publish-profile=$publish_profile" >> $GITHUB_OUTPUT
- name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Deploy app
uses: azure/webapps-deploy@4bca689e4c7129e55923ea9c45401b22dc6aa96f # v2.2.11
with:
app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }}
publish-profile: ${{ steps.retrieve-secrets.outputs.publish-profile }}
package: ./${{ matrix.name }}.zip
slot-name: "staging"
- name: Start staging slot
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
env:
SERVICE: ${{ matrix.name }}
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
run: |
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
RESOURCE_GROUP=bitwardenappservices
else
RESOURCE_GROUP=bitwarden
fi
az webapp start -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
- name: Update ${{ matrix.name }} deployment status to success
if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }}
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
with:
token: "${{ secrets.GITHUB_TOKEN }}"
state: "success"
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
- name: Update ${{ matrix.name }} deployment status to failure
if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }}
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
with:
token: "${{ secrets.GITHUB_TOKEN }}"
state: "failure"
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
release-docker:
name: Build Docker images
runs-on: ubuntu-22.04
needs: setup
env:
_RELEASE_VERSION: ${{ needs.setup.outputs.release_version }}
_BRANCH_NAME: ${{ needs.setup.outputs.branch-name }}
strategy:
fail-fast: false
matrix:
include:
- project_name: Admin
- project_name: Api
- project_name: Attachments
- project_name: Billing
- project_name: Events
- project_name: EventsProcessor
- project_name: Icons
- project_name: Identity
- project_name: MsSql
- project_name: MsSqlMigratorUtility
- project_name: Nginx
- project_name: Notifications
- project_name: Scim
- project_name: Server
- project_name: Setup
- project_name: Sso
steps:
- name: Print environment
env:
RELEASE_OPTION: ${{ github.event.inputs.release_type }}
run: |
whoami
docker --version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
echo "Github Release Option: $RELEASE_OPTION"
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up project name
id: setup
run: |
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
########## ACR PROD ##########
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Pull latest project image
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest
else
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
fi
- name: Tag version and latest
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest
fi
- name: Push version and latest image
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun
else
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest
fi
- name: Log out of Docker
run: docker logout
release:
name: Create GitHub release
runs-on: ubuntu-22.04
needs:
- setup
- deploy
needs: setup
steps:
- name: Download latest release Docker stubs
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
if: ${{ inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build.yml
@ -286,7 +71,7 @@ jobs:
swagger.json"
- name: Dry Run - Download latest release Docker stubs
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
if: ${{ inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
workflow: build.yml
@ -299,8 +84,8 @@ jobs:
swagger.json"
- name: Create release
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@6c75be85e571768fa31b40abf38de58ba0397db5 # v1.13.0
if: ${{ inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@cdcc88a9acf3ca41c16c37bb7d21b9ad48560d87 # v1.15.0
with:
artifacts: "docker-stub-US.zip,
docker-stub-US-sha256.txt,

View File

@ -0,0 +1,280 @@
name: Repository management
on:
workflow_dispatch:
inputs:
task:
default: "Version Bump"
description: "Task to execute"
options:
- "Version Bump"
- "Version Bump and Cut rc"
- "Version Bump and Cut hotfix-rc"
required: true
type: choice
target_ref:
default: "main"
description: "Branch/Tag to target for cut"
required: true
type: string
version_number_override:
description: "New version override (leave blank for automatic calculation, example: '2024.1.0')"
required: false
type: string
jobs:
setup:
name: Setup
runs-on: ubuntu-24.04
outputs:
branch: ${{ steps.set-branch.outputs.branch }}
steps:
- name: Set branch
id: set-branch
env:
TASK: ${{ inputs.task }}
run: |
if [[ "$TASK" == "Version Bump" ]]; then
BRANCH="none"
elif [[ "$TASK" == "Version Bump and Cut rc" ]]; then
BRANCH="rc"
elif [[ "$TASK" == "Version Bump and Cut hotfix-rc" ]]; then
BRANCH="hotfix-rc"
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
cut_branch:
name: Cut branch
if: ${{ needs.setup.outputs.branch != 'none' }}
needs: setup
runs-on: ubuntu-24.04
steps:
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out target ref
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Cut branch
env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME
bump_version:
name: Bump Version
if: ${{ always() }}
runs-on: ubuntu-24.04
needs:
- cut_branch
- setup
outputs:
version: ${{ steps.set-final-version-output.outputs.version }}
steps:
- name: Validate version input format
if: ${{ inputs.version_number_override != '' }}
uses: bitwarden/gh-actions/version-check@main
with:
version: ${{ inputs.version_number_override }}
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Get current version
id: current-version
run: |
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
- name: Verify input version
if: ${{ inputs.version_number_override != '' }}
env:
CURRENT_VERSION: ${{ steps.current-version.outputs.version }}
NEW_VERSION: ${{ inputs.version_number_override }}
run: |
# Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY
exit 1
fi
# Check if version is newer.
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
if [ $? -eq 0 ]; then
echo "Version is newer than the current version."
else
echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Calculate next release version
if: ${{ inputs.version_number_override == '' }}
id: calculate-next-version
uses: bitwarden/gh-actions/version-next@main
with:
version: ${{ steps.current-version.outputs.version }}
- name: Bump version props - Version Override
if: ${{ inputs.version_number_override != '' }}
id: bump-version-override
uses: bitwarden/gh-actions/version-bump@main
with:
file_path: "Directory.Build.props"
version: ${{ inputs.version_number_override }}
- name: Bump version props - Automatic Calculation
if: ${{ inputs.version_number_override == '' }}
id: bump-version-automatic
uses: bitwarden/gh-actions/version-bump@main
with:
file_path: "Directory.Build.props"
version: ${{ steps.calculate-next-version.outputs.version }}
- name: Set final version output
id: set-final-version-output
env:
VERSION: ${{ inputs.version_number_override }}
run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=$VERSION" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes
run: git push
cherry_pick:
name: Cherry-Pick Commit(s)
if: ${{ needs.setup.outputs.branch != 'none' }}
runs-on: ubuntu-24.04
needs:
- bump_version
- setup
steps:
- name: Generate GH App token
uses: actions/create-github-app-token@c1a285145b9d317df6ced56c09f525b5c2b6f755 # v1.11.1
id: app-token
with:
app-id: ${{ secrets.BW_GHAPP_ID }}
private-key: ${{ secrets.BW_GHAPP_KEY }}
- name: Check out main branch
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: main
token: ${{ steps.app-token.outputs.token }}
- name: Configure Git
run: |
git config --local user.email "actions@github.com"
git config --local user.name "Github Actions"
- name: Install xmllint
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Perform cherry-pick(s)
env:
CUT_BRANCH: ${{ needs.setup.outputs.branch }}
run: |
# Function for cherry-picking
cherry_pick () {
local source_branch=$1
local destination_branch=$2
# Get project commit/version from source branch
git switch $source_branch
SOURCE_COMMIT=$(git log --reverse --pretty=format:"%H" --max-count=1 Directory.Build.props)
SOURCE_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# Get project commit/version from destination branch
git switch $destination_branch
DESTINATION_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
if [[ "$DESTINATION_VERSION" != "$SOURCE_VERSION" ]]; then
git cherry-pick --strategy-option=theirs -x $SOURCE_COMMIT
git push -u origin $destination_branch
fi
}
# If we are cutting 'hotfix-rc':
if [[ "$CUT_BRANCH" == "hotfix-rc" ]]; then
# If the 'rc' branch exists:
if [[ $(git ls-remote --heads origin rc) ]]; then
# Chery-pick from 'rc' into 'hotfix-rc'
cherry_pick rc hotfix-rc
# Cherry-pick from 'main' into 'rc'
cherry_pick main rc
# If the 'rc' branch does not exist:
else
# Cherry-pick from 'main' into 'hotfix-rc'
cherry_pick main hotfix-rc
fi
# If we are cutting 'rc':
elif [[ "$CUT_BRANCH" == "rc" ]]; then
# Cherry-pick from 'main' into 'rc'
cherry_pick main rc
fi
move_future_db_scripts:
name: Move finalization database scripts
needs: cherry_pick
uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit

View File

@ -26,12 +26,12 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Scan with Checkmarx
uses: checkmarx/ast-github-action@749fec53e0db0f6404a97e2e0807c3e80e3583a7 #2.0.23
uses: checkmarx/ast-github-action@184bf2f64f55d1c93fd6636d539edf274703e434 # 2.0.41
env:
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
with:
@ -46,7 +46,7 @@ jobs:
--output-path . ${{ env.INCREMENTAL }}
- name: Upload Checkmarx results to GitHub
uses: github/codeql-action/upload-sarif@1b1aada464948af03b950897e5eb522f92603cc2 # v3.24.9
uses: github/codeql-action/upload-sarif@dd746615b3b9d728a6a37ca2045b68ca76d4841a # v3.28.8
with:
sarif_file: cx_result.sarif
@ -59,19 +59,32 @@ jobs:
pull-requests: write
steps:
- name: Set up JDK 17
uses: actions/setup-java@7a6d8a8234af8eb26422e24e3006232cccaa061b # v4.6.0
with:
java-version: 17
distribution: "zulu"
- name: Check out repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
- name: Set up .NET
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Install SonarCloud scanner
run: dotnet tool install dotnet-sonarscanner -g
- name: Scan with SonarCloud
uses: sonarsource/sonarcloud-github-action@49e6cd3b187936a73b8280d59ffd9da69df63ec9 # v2.1.1
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
args: >
-Dsonar.organization=${{ github.repository_owner }}
-Dsonar.projectKey=${{ github.repository_owner }}_${{ github.event.repository.name }}
-Dsonar.tests=test/
run: |
dotnet-sonarscanner begin /k:"${{ github.repository_owner }}_${{ github.event.repository.name }}" \
/d:sonar.test.inclusions=test/,bitwarden_license/test/ \
/d:sonar.exclusions=test/,bitwarden_license/test/ \
/o:"${{ github.repository_owner }}" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" \
/d:sonar.host.url="https://sonarcloud.io" ${{ contains(github.event_name, 'pull_request') && format('/d:sonar.pullrequest.key={0}', github.event.pull_request.number) || '' }}
dotnet build
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"

View File

@ -1,4 +1,3 @@
---
name: Staleness
on:
workflow_dispatch:
@ -11,7 +10,7 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check
uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
with:
stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes"

View File

@ -1,64 +0,0 @@
---
name: Stop staging slots
on:
workflow_dispatch:
inputs: {}
jobs:
stop-slots:
name: Stop slots
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
include:
- name: Api
- name: Admin
- name: Billing
- name: Events
- name: Sso
- name: Identity
steps:
- name: Setup
id: setup
run: |
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.name }}"
echo "NAME_LOWER: $NAME_LOWER"
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
env:
VAULT_NAME: "bitwarden-ci"
run: |
webapp_name=$(
az keyvault secret show --vault-name $VAULT_NAME \
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
--query value --output tsv
)
echo "::add-mask::$webapp_name"
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
- name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Stop staging slot
env:
SERVICE: ${{ matrix.name }}
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
run: |
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
RESOURCE_GROUP=bitwardenappservices
else
RESOURCE_GROUP=bitwarden
fi
az webapp stop -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging

View File

@ -1,4 +1,3 @@
---
name: Database testing
on:
@ -9,7 +8,7 @@ on:
- "rc"
- "hotfix-rc"
paths:
- ".github/workflows/infrastructure-tests.yml" # This file
- ".github/workflows/test-database.yml" # This file
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
@ -18,9 +17,10 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions
pull_request:
paths:
- ".github/workflows/infrastructure-tests.yml" # This file
- ".github/workflows/test-database.yml" # This file
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
@ -29,17 +29,37 @@ on:
- "src/Infrastructure.Dapper/**" # Changes to SQL Server Dapper Repository Layer
- "src/Infrastructure.EntityFramework/**" # Changes to Entity Framework Repository Layer
- "test/Infrastructure.IntegrationTest/**" # Any changes to the tests
- "src/**/Entities/**/*.cs" # Database entity definitions
jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
test:
name: Run tests
runs-on: ubuntu-22.04
needs: check-test-secrets
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Restore tools
run: dotnet tool restore
@ -52,10 +72,33 @@ jobs:
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
- name: Add MariaDB for unified
# Use a different port than MySQL
run: |
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
# I've seen the SQL Server container not be ready for commands right after starting up and just needing a bit longer to be ready
- name: Sleep
run: sleep 15s
- name: Checking pending model changes (MySQL)
working-directory: "util/MySqlMigrations"
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Checking pending model changes (Postgres)
working-directory: "util/PostgresMigrations"
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
- name: Checking pending model changes (SQLite)
working-directory: "util/SqliteMigrations"
run: 'dotnet ef migrations has-pending-model-changes -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Data Source=${{ runner.temp }}/test.db"
- name: Migrate SQL Server
run: 'dotnet run --project util/MsSqlMigratorUtility/ "$CONN_STR"'
env:
@ -67,6 +110,12 @@ jobs:
env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Migrate MariaDB
working-directory: "util/MySqlMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
- name: Migrate Postgres
working-directory: "util/PostgresMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
@ -94,18 +143,40 @@ jobs:
# Default Sqlite
BW_TEST_DATABASES__3__TYPE: "Sqlite"
BW_TEST_DATABASES__3__CONNECTIONSTRING: "Data Source=${{ runner.temp }}/test.db"
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx"
# Unified 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"
shell: pwsh
- name: Print MySQL Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
- name: Print MariaDB Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
- name: Print Postgres Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
- name: Print MSSQL Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
- name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
if: always()
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with:
name: Test Results
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2
- name: Docker Compose down
if: always()
working-directory: "dev"
@ -117,10 +188,10 @@ jobs:
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment
run: |
@ -134,7 +205,7 @@ jobs:
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: sql.dacpac
path: Sql.dacpac
@ -160,7 +231,7 @@ jobs:
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: report.xml
path: |
@ -171,7 +242,7 @@ jobs:
run: |
if grep -q "<Operations>" "report.xml"; then
echo
echo "Migrations are out of sync with sqlproj!"
echo "Migration files are not in sync with the files in the Sql project. Review to make sure that any stored procedures / other db changes match with the stored procedures in the Sql project."
exit 1
else
echo "Report looks good"

View File

@ -1,4 +1,3 @@
---
name: Testing
on:
@ -14,18 +13,43 @@ env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
check-test-secrets:
name: Check for test secrets
runs-on: ubuntu-22.04
outputs:
available: ${{ steps.check-test-secrets.outputs.available }}
permissions:
contents: read
steps:
- name: Check
id: check-test-secrets
run: |
if [ "${{ secrets.CODECOV_TOKEN }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT;
else
echo "available=false" >> $GITHUB_OUTPUT;
fi
testing:
name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-22.04
needs: check-test-secrets
permissions:
checks: write
contents: read
pull-requests: write
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
uses: actions/setup-dotnet@87b7050bc53ea08284295505d98d2aa94301e852 # v4.2.0
- name: Print environment
run: |
@ -44,8 +68,8 @@ 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@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
if: always()
uses: dorny/test-reporter@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
with:
name: Test Results
path: "**/*-test-results.trx"
@ -53,6 +77,4 @@ jobs:
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@0cfda1dd0a4ad9efc75517f399d859cd1ea4ced1 # v4.0.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
uses: codecov/codecov-action@1e68e06f1dbfde0e4cefc87efeba9e4643565303 # v5.1.2

View File

@ -1,249 +0,0 @@
---
name: Version Bump
on:
workflow_dispatch:
inputs:
version_number_override:
description: "New version override (leave blank for automatic calculation, example: '2024.1.0')"
required: false
type: string
cut_rc_branch:
description: "Cut RC branch?"
default: true
type: boolean
jobs:
bump_version:
name: Bump Version
runs-on: ubuntu-22.04
outputs:
version: ${{ steps.set-final-version-output.outputs.version }}
steps:
- name: Validate version input
if: ${{ inputs.version_number_override != '' }}
uses: bitwarden/gh-actions/version-check@main
with:
version: ${{ inputs.version_number_override }}
- name: Check out branch
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
with:
ref: main
- name: Check if RC branch exists
if: ${{ inputs.cut_rc_branch == true }}
run: |
remote_rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
if [[ "${remote_rc_branch_check}" -gt 0 ]]; then
echo "Remote RC branch exists."
echo "Please delete current RC branch before running again."
exit 1
fi
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
keyvault: "bitwarden-ci"
secrets: "github-gpg-private-key,
github-gpg-private-key-passphrase,
github-pat-bitwarden-devops-bot-repo-scope"
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
git_user_signingkey: true
git_commit_gpgsign: true
- name: Set up Git
run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot"
- name: Create version branch
id: create-branch
run: |
NAME=version_bump_${{ github.ref_name }}_$(date +"%Y-%m-%d")
git switch -c $NAME
echo "name=$NAME" >> $GITHUB_OUTPUT
- name: Install xmllint
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Get current version
id: current-version
run: |
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
- name: Verify input version
if: ${{ inputs.version_number_override != '' }}
env:
CURRENT_VERSION: ${{ steps.current-version.outputs.version }}
NEW_VERSION: ${{ inputs.version_number_override }}
run: |
# Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Version has not changed."
exit 1
fi
# Check if version is newer.
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
if [ $? -eq 0 ]; then
echo "Version check successful."
else
echo "Version check failed."
exit 1
fi
- name: Calculate next release version
if: ${{ inputs.version_number_override == '' }}
id: calculate-next-version
uses: bitwarden/gh-actions/version-next@main
with:
version: ${{ steps.current-version.outputs.version }}
- name: Bump version props - Version Override
if: ${{ inputs.version_number_override != '' }}
id: bump-version-override
uses: bitwarden/gh-actions/version-bump@main
with:
file_path: "Directory.Build.props"
version: ${{ inputs.version_number_override }}
- name: Bump version props - Automatic Calculation
if: ${{ inputs.version_number_override == '' }}
id: bump-version-automatic
uses: bitwarden/gh-actions/version-bump@main
with:
file_path: "Directory.Build.props"
version: ${{ steps.calculate-next-version.outputs.version }}
- name: Set final version output
id: set-final-version-output
run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=${{ inputs.version_number_override }}" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
fi
- name: Check if version changed
id: version-changed
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "changes_to_commit=TRUE" >> $GITHUB_OUTPUT
else
echo "changes_to_commit=FALSE" >> $GITHUB_OUTPUT
echo "No changes to commit!";
fi
- name: Commit files
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a
- name: Push changes
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
run: git push -u origin $PR_BRANCH
- name: Create version PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
id: create-pr
env:
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
TITLE: "Bump version to ${{ steps.set-final-version-output.outputs.version }}"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "main" \
--head "$PR_BRANCH" \
--label "version update" \
--label "automated pr" \
--body "
## Type of change
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [X] Other
## Objective
Automated version bump to ${{ steps.set-final-version-output.outputs.version }}")
echo "pr_number=${PR_URL##*/}" >> $GITHUB_OUTPUT
- name: Approve PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr review $PR_NUMBER --approve
- name: Merge PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
GH_TOKEN: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
PR_NUMBER: ${{ steps.create-pr.outputs.pr_number }}
run: gh pr merge $PR_NUMBER --squash --auto --delete-branch
- name: Report upcoming release version to Slack
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
uses: bitwarden/gh-actions/report-upcoming-release-version@main
with:
version: ${{ steps.set-final-version-output.outputs.version }}
project: ${{ github.repository }}
AZURE_KV_CI_SERVICE_PRINCIPAL: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
cut_rc:
name: Cut RC branch
if: ${{ inputs.cut_rc_branch == true }}
needs: bump_version
runs-on: ubuntu-22.04
steps:
- name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: main
- name: Install xmllint
run: |
sudo apt-get update
sudo apt-get install -y libxml2-utils
- name: Verify version has been updated
env:
NEW_VERSION: ${{ needs.bump_version.outputs.version }}
run: |
# Wait for version to change.
while : ; do
echo "Waiting for version to be updated..."
git pull --force
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
# If the versions don't match we continue the loop, otherwise we break out of the loop.
[[ "$NEW_VERSION" != "$CURRENT_VERSION" ]] || break
sleep 10
done
- name: Cut RC branch
run: |
git switch --quiet --create rc
git push --quiet --set-upstream origin rc
move-future-db-scripts:
name: Move finalization database scripts
needs: cut_rc
uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit

7
.gitignore vendored
View File

@ -205,16 +205,15 @@ mail_dist/
src/Core/Properties/launchSettings.json
*.override.env
**/*.DS_Store
src/Admin/wwwroot/lib
src/Admin/wwwroot/css
src/Admin/wwwroot/assets
.vscode/*
**/.vscode/*
bitwarden_license/src/Sso/wwwroot/lib
bitwarden_license/src/Sso/wwwroot/css
bitwarden_license/src/Sso/wwwroot/assets
.github/test/build.secrets
**/CoverageOutput/
.idea/*
**/**.swp
.mono
src/Admin/Admin.zip
src/Api/Api.zip

18
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"recommendations": [
"nick-rudenko.back-n-forth",
"streetsidesoftware.code-spell-checker",
"MS-vsliveshare.vsliveshare",
"mhutchie.git-graph",
"donjayamanne.githistory",
"eamodio.gitlens",
"jakebathman.mysql-syntax",
"ckolkman.vscode-postgres",
"ms-dotnettools.csharp",
"formulahendry.dotnet-test-explorer",
"adrianwilczynski.user-secrets"
]
}

17
.vscode/launch.json vendored
View File

@ -33,6 +33,21 @@
"preLaunchTask": "buildIdentityApiAdmin",
"stopAll": true
},
{
"name": "API, Identity, SSO",
"configurations": [
"run-API",
"run-Identity",
"run-Sso"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 4
},
"preLaunchTask": "buildIdentityApiSso",
"stopAll": true
},
{
"name": "Full Server",
"configurations": [
@ -49,7 +64,7 @@
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 4
"order": 5
},
"preLaunchTask": "buildFullServer",
"stopAll": true

46
.vscode/tasks.json vendored
View File

@ -3,6 +3,7 @@
"tasks": [
{
"label": "buildIdentityApi",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildIdentity",
@ -14,6 +15,7 @@
},
{
"label": "buildIdentityApiAdmin",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildIdentity",
@ -24,8 +26,22 @@
"$msCompile"
]
},
{
"label": "buildIdentityApiSso",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildIdentity",
"buildAPI",
"buildSso"
],
"problemMatcher": [
"$msCompile"
]
},
{
"label": "buildFullServer",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
@ -40,6 +56,7 @@
},
{
"label": "buildSelfHostBit",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
@ -52,6 +69,7 @@
},
{
"label": "buildSelfHostOss",
"hide": true,
"dependsOrder": "sequence",
"dependsOn": [
"buildAdmin",
@ -62,6 +80,7 @@
},
{
"label": "buildIcons",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -74,6 +93,7 @@
},
{
"label": "buildPortal",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -86,6 +106,7 @@
},
{
"label": "buildSso",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -98,6 +119,7 @@
},
{
"label": "buildEvents",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -110,6 +132,7 @@
},
{
"label": "buildEventsProcessor",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -122,6 +145,7 @@
},
{
"label": "buildAdmin",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -134,6 +158,7 @@
},
{
"label": "buildIdentity",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -146,6 +171,7 @@
},
{
"label": "buildAPI",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -162,6 +188,7 @@
},
{
"label": "buildNotifications",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -178,6 +205,7 @@
},
{
"label": "buildBilling",
"hide": true,
"command": "dotnet",
"type": "process",
"args": [
@ -192,20 +220,6 @@
"isDefault": true
}
},
{
"label": "clean",
"type": "shell",
"command": "dotnet clean",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"problemMatcher": "$msCompile"
},
{
"label": "test",
"type": "shell",
@ -225,13 +239,15 @@
"problemMatcher": "$msCompile"
},
{
"label": "Setup Secrets",
"label": "Set Up Secrets",
"detail": "A task to run setup_secrets.ps1",
"type": "shell",
"command": "pwsh -WorkingDirectory ${workspaceFolder}/dev -Command '${workspaceFolder}/dev/setup_secrets.ps1 -clear:$${input:setupSecretsClear}'",
"problemMatcher": []
},
{
"label": "Install Dev Cert",
"detail": "A task to install the Bitwarden developer cert to run your local install as an admin.",
"type": "shell",
"command": "dotnet tool install -g dotnet-certificate-tool -g && certificate-tool add --file ${workspaceFolder}/dev/dev.pfx --password '${input:certPassword}'",
"problemMatcher": []

View File

@ -3,11 +3,17 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2024.5.1</Version>
<Version>2025.3.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
<!-- Treat it as a test project if the project hasn't set their own value and it follows our test project conventions -->
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
<!-- Uncomment the below line when we are ready to enable nullable repo wide -->
<!-- <Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable> -->
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
</PropertyGroup>
<!--

View File

@ -44,7 +44,7 @@ _These dependencies are free to use._
### Linux & macOS
```
```sh
curl -s -L -o bitwarden.sh \
"https://func.bitwarden.com/api/dl/?app=self-host&platform=linux" \
&& chmod +x bitwarden.sh
@ -54,7 +54,7 @@ curl -s -L -o bitwarden.sh \
### Windows
```
```cmd
Invoke-RestMethod -OutFile bitwarden.ps1 `
-Uri "https://func.bitwarden.com/api/dl/?app=self-host&platform=windows"
.\bitwarden.ps1 -install

View File

@ -1,4 +1,4 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16
VisualStudioVersion = 16.0.29102.190
@ -18,7 +18,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
TRADEMARK_GUIDELINES.md = TRADEMARK_GUIDELINES.md
SECURITY.md = SECURITY.md
NuGet.Config = NuGet.Config
LICENSE_FAQ.md = LICENSE_FAQ.md
LICENSE_BITWARDEN.txt = LICENSE_BITWARDEN.txt
LICENSE_AGPL.txt = LICENSE_AGPL.txt
@ -124,6 +123,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EventsProcessor.Test", "tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Notifications.Test", "test\Notifications.Test\Notifications.Test.csproj", "{90D85D8F-5577-4570-A96E-5A2E185F0F6F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.Dapper.Test", "test\Infrastructure.Dapper.Test\Infrastructure.Dapper.Test.csproj", "{4A725DB3-BE4F-4C23-9087-82D0610D67AF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Events.IntegrationTest", "test\Events.IntegrationTest\Events.IntegrationTest.csproj", "{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -308,6 +311,14 @@ Global
{90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{90D85D8F-5577-4570-A96E-5A2E185F0F6F}.Release|Any CPU.Build.0 = Release|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4A725DB3-BE4F-4C23-9087-82D0610D67AF}.Release|Any CPU.Build.0 = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -357,6 +368,8 @@ Global
{916AFD8C-30AF-49B6-A5C9-28CA1B5D9298} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{81673EFB-7134-4B4B-A32F-1EA05F0EF3CE} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{90D85D8F-5577-4570-A96E-5A2E185F0F6F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4A725DB3-BE4F-4C23-9087-82D0610D67AF} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4F4C63A9-AEE2-48C4-AB86-A5BCD665E401} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -1,15 +1,14 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -20,25 +19,43 @@ public class CreateProviderCommand : ICreateProviderCommand
private readonly IProviderService _providerService;
private readonly IUserRepository _userRepository;
private readonly IProviderPlanRepository _providerPlanRepository;
private readonly IFeatureService _featureService;
public CreateProviderCommand(
IProviderRepository providerRepository,
IProviderUserRepository providerUserRepository,
IProviderService providerService,
IUserRepository userRepository,
IProviderPlanRepository providerPlanRepository,
IFeatureService featureService)
IProviderPlanRepository providerPlanRepository)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerService = providerService;
_userRepository = userRepository;
_providerPlanRepository = providerPlanRepository;
_featureService = featureService;
}
public async Task CreateMspAsync(Provider provider, string ownerEmail, int teamsMinimumSeats, int enterpriseMinimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
await Task.WhenAll(
CreateProviderPlanAsync(providerId, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlanAsync(providerId, PlanType.EnterpriseMonthly, enterpriseMinimumSeats));
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
}
public async Task CreateMultiOrganizationEnterpriseAsync(Provider provider, string ownerEmail, PlanType plan, int minimumSeats)
{
var providerId = await CreateProviderAsync(provider, ownerEmail);
await CreateProviderPlanAsync(providerId, plan, minimumSeats);
}
private async Task<Guid> CreateProviderAsync(Provider provider, string ownerEmail)
{
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null)
@ -46,12 +63,7 @@ public class CreateProviderCommand : ICreateProviderCommand
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
}
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
if (isConsolidatedBillingEnabled)
{
provider.Gateway = GatewayType.Stripe;
}
provider.Gateway = GatewayType.Stripe;
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Pending);
@ -63,27 +75,10 @@ public class CreateProviderCommand : ICreateProviderCommand
Status = ProviderUserStatusType.Confirmed,
};
if (isConsolidatedBillingEnabled)
{
var providerPlans = new List<ProviderPlan>
{
CreateProviderPlan(provider.Id, PlanType.TeamsMonthly, teamsMinimumSeats),
CreateProviderPlan(provider.Id, PlanType.EnterpriseMonthly, enterpriseMinimumSeats)
};
foreach (var providerPlan in providerPlans)
{
await _providerPlanRepository.CreateAsync(providerPlan);
}
}
await _providerUserRepository.CreateAsync(providerUser);
await _providerService.SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task CreateResellerAsync(Provider provider)
{
await ProviderRepositoryCreateAsync(provider, ProviderStatusType.Created);
return provider.Id;
}
private async Task ProviderRepositoryCreateAsync(Provider provider, ProviderStatusType status)
@ -94,9 +89,9 @@ public class CreateProviderCommand : ICreateProviderCommand
await _providerRepository.CreateAsync(provider);
}
private ProviderPlan CreateProviderPlan(Guid providerId, PlanType planType, int seatMinimum)
private async Task CreateProviderPlanAsync(Guid providerId, PlanType planType, int seatMinimum)
{
return new ProviderPlan
var plan = new ProviderPlan
{
ProviderId = providerId,
PlanType = planType,
@ -104,5 +99,6 @@ public class CreateProviderCommand : ICreateProviderCommand
PurchasedSeats = 0,
AllocatedSeats = 0
};
await _providerPlanRepository.CreateAsync(plan);
}
}

View File

@ -1,18 +1,16 @@
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers;
@ -20,35 +18,41 @@ namespace Bit.Commercial.Core.AdminConsole.Providers;
public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand
{
private readonly IEventService _eventService;
private readonly ILogger<RemoveOrganizationFromProviderCommand> _logger;
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter;
private readonly IScaleSeatsCommand _scaleSeatsCommand;
private readonly IFeatureService _featureService;
private readonly IProviderBillingService _providerBillingService;
private readonly ISubscriberService _subscriberService;
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
private readonly IPricingClient _pricingClient;
public RemoveOrganizationFromProviderCommand(
IEventService eventService,
ILogger<RemoveOrganizationFromProviderCommand> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter,
IScaleSeatsCommand scaleSeatsCommand,
IFeatureService featureService)
IFeatureService featureService,
IProviderBillingService providerBillingService,
ISubscriberService subscriberService,
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
IPricingClient pricingClient)
{
_eventService = eventService;
_logger = logger;
_mailService = mailService;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter;
_scaleSeatsCommand = scaleSeatsCommand;
_featureService = featureService;
_providerBillingService = providerBillingService;
_subscriberService = subscriberService;
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
_pricingClient = pricingClient;
}
public async Task RemoveOrganizationFromProvider(
@ -64,7 +68,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
throw new BadRequestException("Failed to remove organization. Please contact support.");
}
if (!await _organizationService.HasConfirmedOwnersExceptAsync(
if (!await _hasConfirmedOwnersExceptQuery.HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
includeProvider: false))
@ -99,24 +103,17 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
Provider provider,
IEnumerable<string> organizationOwnerEmails)
{
if (!organization.IsStripeEnabled())
if (provider.IsBillable() &&
organization.IsValidClient() &&
!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
return;
}
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Description = string.Empty,
Email = organization.BillingEmail
});
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
var customerUpdateOptions = new CustomerUpdateOptions
{
Coupon = string.Empty,
Email = organization.BillingEmail
};
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
if (isConsolidatedBillingEnabled && provider.Status == ProviderStatusType.Billable)
{
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
@ -130,25 +127,37 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations,
Items = [new SubscriptionItemOptions { Price = plan.StripeSeatPlanId, Quantity = organization.Seats }]
Items = [new SubscriptionItemOptions { Price = plan.PasswordManager.StripeSeatPlanId, Quantity = organization.Seats }]
};
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
organization.GatewaySubscriptionId = subscription.Id;
organization.Status = OrganizationStatusType.Created;
await _scaleSeatsCommand.ScalePasswordManagerSeats(provider, organization.PlanType,
-(organization.Seats ?? 0));
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
}
else
else if (organization.IsStripeEnabled())
{
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
{
CollectionMethod = "send_invoice",
DaysUntilDue = 30
};
return;
}
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
{
Coupon = string.Empty,
Email = organization.BillingEmail
});
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
{
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
DaysUntilDue = 30
});
await _subscriberService.RemovePaymentSource(organization);
}
await _mailService.SendProviderUpdatePaymentMethod(

View File

@ -7,7 +7,9 @@ using Bit.Core.AdminConsole.Models.Business.Provider;
using Bit.Core.AdminConsole.Models.Business.Tokenables;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -26,7 +28,11 @@ namespace Bit.Commercial.Core.AdminConsole.Services;
public class ProviderService : IProviderService
{
public static PlanType[] ProviderDisallowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 };
private static readonly PlanType[] _resellerDisallowedOrganizationTypes = [
PlanType.Free,
PlanType.FamiliesAnnually,
PlanType.FamiliesAnnually2019
];
private readonly IDataProtector _dataProtector;
private readonly IMailService _mailService;
@ -44,6 +50,8 @@ public class ProviderService : IProviderService
private readonly IFeatureService _featureService;
private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IProviderBillingService _providerBillingService;
private readonly IPricingClient _pricingClient;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
@ -52,7 +60,7 @@ public class ProviderService : IProviderService
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
IApplicationCacheService applicationCacheService)
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
@ -70,9 +78,11 @@ public class ProviderService : IProviderService
_featureService = featureService;
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
_applicationCacheService = applicationCacheService;
_providerBillingService = providerBillingService;
_pricingClient = pricingClient;
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null)
@ -97,7 +107,15 @@ public class ProviderService : IProviderService
throw new BadRequestException("Invalid owner.");
}
provider.Status = ProviderStatusType.Created;
if (taxInfo == null || string.IsNullOrEmpty(taxInfo.BillingAddressCountry) || string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
{
throw new BadRequestException("Both address and postal code are required to set up your provider.");
}
var customer = await _providerBillingService.SetupCustomer(provider, taxInfo);
provider.GatewayCustomerId = customer.Id;
var subscription = await _providerBillingService.SetupSubscription(provider);
provider.GatewaySubscriptionId = subscription.Id;
provider.Status = ProviderStatusType.Billable;
await _providerRepository.UpsertAsync(provider);
providerUser.Key = key;
@ -372,7 +390,9 @@ public class ProviderService : IProviderService
var organization = await _organizationRepository.GetByIdAsync(organizationId);
ThrowOnInvalidPlanType(organization.PlanType);
var provider = await _providerRepository.GetByIdAsync(providerId);
ThrowOnInvalidPlanType(provider.Type, organization.PlanType);
if (organization.UseSecretsManager)
{
@ -387,8 +407,6 @@ public class ProviderService : IProviderService
Key = key,
};
var provider = await _providerRepository.GetByIdAsync(providerId);
await ApplyProviderPriceRateAsync(organization, provider);
await _providerOrganizationRepository.CreateAsync(providerOrganization);
@ -435,28 +453,33 @@ public class ProviderService : IProviderService
return;
}
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId, GetStripeSeatPlanId(organization.PlanType));
var extractedPlanType = PlanTypeMappings(organization);
if (subscriptionItem != null)
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
{
await UpdateSubscriptionAsync(subscriptionItem, GetStripeSeatPlanId(extractedPlanType), organization);
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
var subscriptionItem = await GetSubscriptionItemAsync(
organization.GatewaySubscriptionId,
plan.PasswordManager.StripeSeatPlanId);
var extractedPlanType = PlanTypeMappings(organization);
var extractedPlan = await _pricingClient.GetPlanOrThrow(extractedPlanType);
if (subscriptionItem != null)
{
await UpdateSubscriptionAsync(subscriptionItem, extractedPlan.PasswordManager.StripeSeatPlanId, organization);
}
}
await _organizationRepository.UpsertAsync(organization);
}
private async Task<Stripe.SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
private async Task<SubscriptionItem> GetSubscriptionItemAsync(string subscriptionId, string oldPlanId)
{
var subscriptionDetails = await _stripeAdapter.SubscriptionGetAsync(subscriptionId);
return subscriptionDetails.Items.Data.FirstOrDefault(item => item.Price.Id == oldPlanId);
}
private static string GetStripeSeatPlanId(PlanType planType)
{
return StaticStore.GetPlan(planType).PasswordManager.StripeSeatPlanId;
}
private async Task UpdateSubscriptionAsync(Stripe.SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
private async Task UpdateSubscriptionAsync(SubscriptionItem subscriptionItem, string extractedPlanType, Organization organization)
{
try
{
@ -521,13 +544,9 @@ public class ProviderService : IProviderService
{
var provider = await _providerRepository.GetByIdAsync(providerId);
var consolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling) && provider.IsBillable();
ThrowOnInvalidPlanType(provider.Type, organizationSignup.Plan);
ThrowOnInvalidPlanType(organizationSignup.Plan, consolidatedBillingEnabled);
var (organization, _, defaultCollection) = consolidatedBillingEnabled
? await _organizationService.SignupClientAsync(organizationSignup)
: await _organizationService.SignUpAsync(organizationSignup, true);
var (organization, _, defaultCollection) = await _organizationService.SignupClientAsync(organizationSignup);
var providerOrganization = new ProviderOrganization
{
@ -539,9 +558,9 @@ public class ProviderService : IProviderService
await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
// If using Flexible Collections, give the owner Can Manage access over the default collection
// Give the owner Can Manage access over the default collection
// The orgUser is not available when the org is created so we have to do it here as part of the invite
var defaultOwnerAccess = organization.FlexibleCollections && defaultCollection != null
var defaultOwnerAccess = defaultCollection != null
?
[
new CollectionAccessSelection
@ -554,17 +573,13 @@ public class ProviderService : IProviderService
]
: Array.Empty<CollectionAccessSelection>();
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null,
new (OrganizationUserInvite, string)[]
{
(
new OrganizationUserInvite
{
Emails = new[] { clientOwnerEmail },
// If using Flexible Collections, AccessAll is deprecated and set to false.
// If not using Flexible Collections, set AccessAll to true (previous behavior)
AccessAll = !organization.FlexibleCollections,
Type = OrganizationUserType.Owner,
Permissions = null,
Collections = defaultOwnerAccess,
@ -667,16 +682,30 @@ public class ProviderService : IProviderService
return confirmedOwnersIds.Except(providerUserIds).Any();
}
private void ThrowOnInvalidPlanType(PlanType requestedType, bool consolidatedBillingEnabled = false)
private void ThrowOnInvalidPlanType(ProviderType providerType, PlanType requestedType)
{
if (consolidatedBillingEnabled && requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
switch (providerType)
{
throw new BadRequestException($"Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
}
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
{
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
case ProviderType.Msp:
if (requestedType is not (PlanType.TeamsMonthly or PlanType.EnterpriseMonthly))
{
throw new BadRequestException($"Managed Service Providers cannot manage organizations with the plan type {requestedType}. Only Teams (Monthly) and Enterprise (Monthly) are allowed.");
}
break;
case ProviderType.MultiOrganizationEnterprise:
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
{
throw new BadRequestException($"Multi-organization Enterprise Providers cannot manage organizations with the plan type {requestedType}. Only Enterprise (Monthly) and Enterprise (Annually) are allowed.");
}
break;
case ProviderType.Reseller:
if (_resellerDisallowedOrganizationTypes.Contains(requestedType))
{
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
}
break;
default:
throw new BadRequestException($"Unsupported provider type {providerType}.");
}
}
}

View File

@ -0,0 +1,29 @@
using System.Globalization;
using Bit.Core.Billing.Entities;
using CsvHelper.Configuration.Attributes;
namespace Bit.Commercial.Core.Billing.Models;
public class ProviderClientInvoiceReportRow
{
public string Client { get; set; }
public string Id { get; set; }
public int Assigned { get; set; }
public int Used { get; set; }
public int Remaining { get; set; }
public string Plan { get; set; }
[Name("Estimated total")]
public string Total { get; set; }
public static ProviderClientInvoiceReportRow From(ProviderInvoiceItem providerInvoiceItem)
=> new()
{
Client = providerInvoiceItem.ClientName,
Id = providerInvoiceItem.ClientId?.ToString(),
Assigned = providerInvoiceItem.AssignedSeats,
Used = providerInvoiceItem.UsedSeats,
Remaining = providerInvoiceItem.AssignedSeats - providerInvoiceItem.UsedSeats,
Plan = providerInvoiceItem.PlanName,
Total = string.Format(new CultureInfo("en-US", false), "{0:C}", providerInvoiceItem.Total)
};
}

View File

@ -0,0 +1,788 @@
using System.Globalization;
using Bit.Commercial.Core.Billing.Models;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Entities;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Repositories;
using Bit.Core.Billing.Services;
using Bit.Core.Billing.Services.Contracts;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using CsvHelper;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Commercial.Core.Billing;
public class ProviderBillingService(
IEventService eventService,
IGlobalSettings globalSettings,
ILogger<ProviderBillingService> logger,
IOrganizationRepository organizationRepository,
IPaymentService paymentService,
IPricingClient pricingClient,
IProviderInvoiceItemRepository providerInvoiceItemRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderPlanRepository providerPlanRepository,
IProviderUserRepository providerUserRepository,
IStripeAdapter stripeAdapter,
ISubscriberService subscriberService,
ITaxService taxService) : IProviderBillingService
{
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task AddExistingOrganization(
Provider provider,
Organization organization,
string key)
{
await stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false
});
var subscription =
await stripeAdapter.SubscriptionCancelAsync(organization.GatewaySubscriptionId,
new SubscriptionCancelOptions
{
CancellationDetails = new SubscriptionCancellationDetailsOptions
{
Comment = $"Organization was added to Provider with ID {provider.Id}"
},
InvoiceNow = true,
Prorate = true,
Expand = ["latest_invoice", "test_clock"]
});
var now = subscription.TestClock?.FrozenTime ?? DateTime.UtcNow;
var wasTrialing = subscription.TrialEnd.HasValue && subscription.TrialEnd.Value > now;
if (!wasTrialing && subscription.LatestInvoice.Status == StripeConstants.InvoiceStatus.Draft)
{
await stripeAdapter.InvoiceFinalizeInvoiceAsync(subscription.LatestInvoiceId,
new InvoiceFinalizeOptions { AutoAdvance = true });
}
var managedPlanType = await GetManagedPlanTypeAsync(provider, organization);
var plan = await pricingClient.GetPlanOrThrow(managedPlanType);
organization.Plan = plan.Name;
organization.PlanType = plan.Type;
organization.MaxCollections = plan.PasswordManager.MaxCollections;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.UsePolicies = plan.HasPolicies;
organization.UseSso = plan.HasSso;
organization.UseGroups = plan.HasGroups;
organization.UseEvents = plan.HasEvents;
organization.UseDirectory = plan.HasDirectory;
organization.UseTotp = plan.HasTotp;
organization.Use2fa = plan.Has2fa;
organization.UseApi = plan.HasApi;
organization.UseResetPassword = plan.HasResetPassword;
organization.SelfHost = plan.HasSelfHost;
organization.UsersGetPremium = plan.UsersGetPremium;
organization.UseCustomPermissions = plan.HasCustomPermissions;
organization.UseScim = plan.HasScim;
organization.UseKeyConnector = plan.HasKeyConnector;
organization.MaxStorageGb = plan.PasswordManager.BaseStorageGb;
organization.BillingEmail = provider.BillingEmail!;
organization.GatewaySubscriptionId = null;
organization.ExpirationDate = null;
organization.MaxAutoscaleSeats = null;
organization.Status = OrganizationStatusType.Managed;
var providerOrganization = new ProviderOrganization
{
ProviderId = provider.Id,
OrganizationId = organization.Id,
Key = key
};
/*
* We have to scale the provider's seats before the ProviderOrganization
* row is inserted so the added organization's seats don't get double counted.
*/
await ScaleSeats(provider, organization.PlanType, organization.Seats!.Value);
await Task.WhenAll(
organizationRepository.ReplaceAsync(organization),
providerOrganizationRepository.CreateAsync(providerOrganization)
);
var clientCustomer = await subscriberService.GetCustomer(organization);
if (clientCustomer.Balance != 0)
{
await stripeAdapter.CustomerBalanceTransactionCreate(provider.GatewayCustomerId,
new CustomerBalanceTransactionCreateOptions
{
Amount = clientCustomer.Balance,
Currency = "USD",
Description = $"Unused, prorated time for client organization with ID {organization.Id}."
});
}
await eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Added);
}
public async Task ChangePlan(ChangeProviderPlanCommand command)
{
var plan = await providerPlanRepository.GetByIdAsync(command.ProviderPlanId);
if (plan == null)
{
throw new BadRequestException("Provider plan not found.");
}
if (plan.PlanType == command.NewPlan)
{
return;
}
var oldPlanConfiguration = await pricingClient.GetPlanOrThrow(plan.PlanType);
var newPlanConfiguration = await pricingClient.GetPlanOrThrow(command.NewPlan);
plan.PlanType = command.NewPlan;
await providerPlanRepository.ReplaceAsync(plan);
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, plan.ProviderId);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x =>
x.Price.Id == oldPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId);
var updateOptions = new SubscriptionUpdateOptions
{
Items =
[
new SubscriptionItemOptions
{
Price = newPlanConfiguration.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = oldSubscriptionItem!.Quantity
},
new SubscriptionItemOptions
{
Id = oldSubscriptionItem.Id,
Deleted = true
}
]
};
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId, updateOptions);
// Refactor later to ?ChangeClientPlanCommand? (ProviderPlanId, ProviderId, OrganizationId)
// 1. Retrieve PlanType and PlanName for ProviderPlan
// 2. Assign PlanType & PlanName to Organization
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(plan.ProviderId);
foreach (var providerOrganization in providerOrganizations)
{
var organization = await organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
throw new ConflictException($"Organization '{providerOrganization.Id}' not found.");
}
organization.PlanType = command.NewPlan;
organization.Plan = newPlanConfiguration.Name;
await organizationRepository.ReplaceAsync(organization);
}
}
public async Task CreateCustomerForClientOrganization(
Provider provider,
Organization organization)
{
ArgumentNullException.ThrowIfNull(provider);
ArgumentNullException.ThrowIfNull(organization);
if (!string.IsNullOrEmpty(organization.GatewayCustomerId))
{
logger.LogWarning("Client organization ({ID}) already has a populated {FieldName}", organization.Id, nameof(organization.GatewayCustomerId));
return;
}
var providerCustomer = await subscriberService.GetCustomerOrThrow(provider, new CustomerGetOptions
{
Expand = ["tax_ids"]
});
var providerTaxId = providerCustomer.TaxIds.FirstOrDefault();
var organizationDisplayName = organization.DisplayName();
var customerCreateOptions = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = providerCustomer.Address?.Country,
PostalCode = providerCustomer.Address?.PostalCode,
Line1 = providerCustomer.Address?.Line1,
Line2 = providerCustomer.Address?.Line2,
City = providerCustomer.Address?.City,
State = providerCustomer.Address?.State
},
Name = organizationDisplayName,
Description = $"{provider.Name} Client Organization",
Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = organization.SubscriberType(),
Value = organizationDisplayName.Length <= 30
? organizationDisplayName
: organizationDisplayName[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
},
TaxIdData = providerTaxId == null ? null :
[
new CustomerTaxIdDataOptions
{
Type = providerTaxId.Type,
Value = providerTaxId.Value
}
]
};
var customer = await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
organization.GatewayCustomerId = customer.Id;
await organizationRepository.ReplaceAsync(organization);
}
public async Task<byte[]> GenerateClientInvoiceReport(
string invoiceId)
{
ArgumentException.ThrowIfNullOrEmpty(invoiceId);
var invoiceItems = await providerInvoiceItemRepository.GetByInvoiceId(invoiceId);
if (invoiceItems.Count == 0)
{
logger.LogError("No provider invoice item records were found for invoice ({InvoiceID})", invoiceId);
return null;
}
var csvRows = invoiceItems.Select(ProviderClientInvoiceReportRow.From);
using var memoryStream = new MemoryStream();
await using var streamWriter = new StreamWriter(memoryStream);
await using var csvWriter = new CsvWriter(streamWriter, CultureInfo.CurrentCulture);
await csvWriter.WriteRecordsAsync(csvRows);
await streamWriter.FlushAsync();
memoryStream.Seek(0, SeekOrigin.Begin);
return memoryStream.ToArray();
}
[RequireFeature(FeatureFlagKeys.P15179_AddExistingOrgsFromProviderPortal)]
public async Task<IEnumerable<AddableOrganization>> GetAddableOrganizations(
Provider provider,
Guid userId)
{
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, userId);
if (providerUser is not { Status: ProviderUserStatusType.Confirmed })
{
throw new UnauthorizedAccessException();
}
var candidates = await organizationRepository.GetAddableToProviderByUserIdAsync(userId, provider.Type);
var active = (await Task.WhenAll(candidates.Select(async organization =>
{
var subscription = await subscriberService.GetSubscription(organization);
return (organization, subscription);
})))
.Where(pair => pair.subscription is
{
Status:
StripeConstants.SubscriptionStatus.Active or
StripeConstants.SubscriptionStatus.Trialing or
StripeConstants.SubscriptionStatus.PastDue
}).ToList();
if (active.Count == 0)
{
return [];
}
return await Task.WhenAll(active.Select(async pair =>
{
var (organization, _) = pair;
var planName = await DerivePlanName(provider, organization);
var addable = new AddableOrganization(
organization.Id,
organization.Name,
planName,
organization.Seats!.Value);
if (providerUser.Type != ProviderUserType.ServiceUser)
{
return addable;
}
var applicablePlanType = await GetManagedPlanTypeAsync(provider, organization);
var requiresPurchase =
await SeatAdjustmentResultsInPurchase(provider, applicablePlanType, organization.Seats!.Value);
return addable with { Disabled = requiresPurchase };
}));
async Task<string> DerivePlanName(Provider localProvider, Organization localOrganization)
{
if (localProvider.Type == ProviderType.Msp)
{
return localOrganization.PlanType switch
{
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => "Enterprise",
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => "Teams",
_ => throw new BillingException()
};
}
var plan = await pricingClient.GetPlanOrThrow(localOrganization.PlanType);
return plan.Name;
}
}
public async Task ScaleSeats(
Provider provider,
PlanType planType,
int seatAdjustment)
{
var providerPlan = await GetProviderPlanAsync(provider, planType);
var seatMinimum = providerPlan.SeatMinimum ?? 0;
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
var update = CurrySeatScalingUpdate(
provider,
providerPlan,
newlyAssignedSeatTotal);
/*
* Below the limit => Below the limit:
* No subscription update required. We can safely update the provider's allocated seats.
*/
if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
providerPlan.AllocatedSeats = newlyAssignedSeatTotal;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
/*
* Below the limit => Above the limit:
* We have to scale the subscription up from the seat minimum to the newly assigned seat total.
*/
else if (currentlyAssignedSeatTotal <= seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
seatMinimum,
newlyAssignedSeatTotal);
}
/*
* Above the limit => Above the limit:
* We have to scale the subscription from the currently assigned seat total to the newly assigned seat total.
*/
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal > seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
newlyAssignedSeatTotal);
}
/*
* Above the limit => Below the limit:
* We have to scale the subscription down from the currently assigned seat total to the seat minimum.
*/
else if (currentlyAssignedSeatTotal > seatMinimum &&
newlyAssignedSeatTotal <= seatMinimum)
{
await update(
currentlyAssignedSeatTotal,
seatMinimum);
}
}
public async Task<bool> SeatAdjustmentResultsInPurchase(
Provider provider,
PlanType planType,
int seatAdjustment)
{
var providerPlan = await GetProviderPlanAsync(provider, planType);
var seatMinimum = providerPlan.SeatMinimum;
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalAsync(provider, planType);
var newlyAssignedSeatTotal = currentlyAssignedSeatTotal + seatAdjustment;
return
// Below the limit to above the limit
(currentlyAssignedSeatTotal <= seatMinimum && newlyAssignedSeatTotal > seatMinimum) ||
// Above the limit to further above the limit
(currentlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > seatMinimum && newlyAssignedSeatTotal > currentlyAssignedSeatTotal);
}
public async Task<Customer> SetupCustomer(
Provider provider,
TaxInfo taxInfo)
{
if (taxInfo is not
{
BillingAddressCountry: not null and not "",
BillingAddressPostalCode: not null and not ""
})
{
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
throw new BillingException();
}
var options = new CustomerCreateOptions
{
Address = new AddressOptions
{
Country = taxInfo.BillingAddressCountry,
PostalCode = taxInfo.BillingAddressPostalCode,
Line1 = taxInfo.BillingAddressLine1,
Line2 = taxInfo.BillingAddressLine2,
City = taxInfo.BillingAddressCity,
State = taxInfo.BillingAddressState
},
Description = provider.DisplayBusinessName(),
Email = provider.BillingEmail,
InvoiceSettings = new CustomerInvoiceSettingsOptions
{
CustomFields =
[
new CustomerInvoiceSettingsCustomFieldOptions
{
Name = provider.SubscriberType(),
Value = provider.DisplayName()?.Length <= 30
? provider.DisplayName()
: provider.DisplayName()?[..30]
}
]
},
Metadata = new Dictionary<string, string>
{
{ "region", globalSettings.BaseServiceUri.CloudRegion }
}
};
if (!string.IsNullOrEmpty(taxInfo.TaxIdNumber))
{
var taxIdType = taxService.GetStripeTaxCode(
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
if (taxIdType == null)
{
logger.LogWarning("Could not infer tax ID type in country '{Country}' with tax ID '{TaxID}'.",
taxInfo.BillingAddressCountry,
taxInfo.TaxIdNumber);
throw new BadRequestException("billingTaxIdTypeInferenceError");
}
options.TaxIdData =
[
new CustomerTaxIdDataOptions { Type = taxIdType, Value = taxInfo.TaxIdNumber }
];
}
if (!string.IsNullOrEmpty(provider.DiscountId))
{
options.Coupon = provider.DiscountId;
}
try
{
return await stripeAdapter.CustomerCreateAsync(options);
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.TaxIdInvalid)
{
throw new BadRequestException("Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
}
}
public async Task<Subscription> SetupSubscription(
Provider provider)
{
ArgumentNullException.ThrowIfNull(provider);
var customer = await subscriberService.GetCustomerOrThrow(provider);
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
if (providerPlans == null || providerPlans.Count == 0)
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured plans", provider.Id);
throw new BillingException();
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
foreach (var providerPlan in providerPlans)
{
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
if (!providerPlan.IsConfigured())
{
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured {ProviderName} plan", provider.Id, plan.Name);
throw new BillingException();
}
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = plan.PasswordManager.StripeProviderPortalSeatPlanId,
Quantity = providerPlan.SeatMinimum
});
}
var subscriptionCreateOptions = new SubscriptionCreateOptions
{
AutomaticTax = new SubscriptionAutomaticTaxOptions
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
Customer = customer.Id,
DaysUntilDue = 30,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
{ "providerId", provider.Id.ToString() }
},
OffSession = true,
ProrationBehavior = StripeConstants.ProrationBehavior.CreateProrations
};
try
{
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
if (subscription.Status == StripeConstants.SubscriptionStatus.Active)
{
return subscription;
}
logger.LogError(
"Newly created provider ({ProviderID}) subscription ({SubscriptionID}) has inactive status: {Status}",
provider.Id,
subscription.Id,
subscription.Status);
throw new BillingException();
}
catch (StripeException stripeException) when (stripeException.StripeError?.Code == StripeConstants.ErrorCodes.CustomerTaxLocationInvalid)
{
throw new BadRequestException("Your location wasn't recognized. Please ensure your country and postal code are valid.");
}
}
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
{
if (command.Configuration.Any(x => x.SeatsMinimum < 0))
{
throw new BadRequestException("Provider seat minimums must be at least 0.");
}
Subscription subscription;
try
{
subscription = await stripeAdapter.ProviderSubscriptionGetAsync(command.GatewaySubscriptionId, command.Id);
}
catch (InvalidOperationException)
{
throw new ConflictException("Subscription not found.");
}
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
var providerPlans = await providerPlanRepository.GetByProviderId(command.Id);
foreach (var newPlanConfiguration in command.Configuration)
{
var providerPlan =
providerPlans.Single(providerPlan => providerPlan.PlanType == newPlanConfiguration.Plan);
if (providerPlan.SeatMinimum != newPlanConfiguration.SeatsMinimum)
{
var newPlan = await pricingClient.GetPlanOrThrow(newPlanConfiguration.Plan);
var priceId = newPlan.PasswordManager.StripeProviderPortalSeatPlanId;
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
if (providerPlan.PurchasedSeats == 0)
{
if (providerPlan.AllocatedSeats > newPlanConfiguration.SeatsMinimum)
{
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - newPlanConfiguration.SeatsMinimum;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = providerPlan.AllocatedSeats
});
}
else
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
});
}
}
else
{
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
if (newPlanConfiguration.SeatsMinimum <= totalSeats)
{
providerPlan.PurchasedSeats = totalSeats - newPlanConfiguration.SeatsMinimum;
}
else
{
providerPlan.PurchasedSeats = 0;
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Id = subscriptionItem.Id,
Price = priceId,
Quantity = newPlanConfiguration.SeatsMinimum
});
}
}
providerPlan.SeatMinimum = newPlanConfiguration.SeatsMinimum;
await providerPlanRepository.ReplaceAsync(providerPlan);
}
}
if (subscriptionItemOptionsList.Count > 0)
{
await stripeAdapter.SubscriptionUpdateAsync(command.GatewaySubscriptionId,
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
}
}
private Func<int, int, Task> CurrySeatScalingUpdate(
Provider provider,
ProviderPlan providerPlan,
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
{
var plan = await pricingClient.GetPlanOrThrow(providerPlan.PlanType);
await paymentService.AdjustSeats(
provider,
plan,
currentlySubscribedSeats,
newlySubscribedSeats);
var newlyPurchasedSeats = newlySubscribedSeats > providerPlan.SeatMinimum
? newlySubscribedSeats - providerPlan.SeatMinimum
: 0;
providerPlan.PurchasedSeats = newlyPurchasedSeats;
providerPlan.AllocatedSeats = newlyAssignedSeats;
await providerPlanRepository.ReplaceAsync(providerPlan);
};
// TODO: Replace with SPROC
private async Task<int> GetAssignedSeatTotalAsync(Provider provider, PlanType planType)
{
var providerOrganizations =
await providerOrganizationRepository.GetManyDetailsByProviderAsync(provider.Id);
var plan = await pricingClient.GetPlanOrThrow(planType);
return providerOrganizations
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
}
// TODO: Replace with SPROC
private async Task<ProviderPlan> GetProviderPlanAsync(Provider provider, PlanType planType)
{
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
var providerPlan = providerPlans.FirstOrDefault(x => x.PlanType == planType);
if (providerPlan == null || !providerPlan.IsConfigured())
{
throw new BillingException(message: "Provider plan is missing or misconfigured");
}
return providerPlan;
}
private async Task<PlanType> GetManagedPlanTypeAsync(
Provider provider,
Organization organization)
{
if (provider.Type == ProviderType.MultiOrganizationEnterprise)
{
return (await providerPlanRepository.GetByProviderId(provider.Id)).First().PlanType;
}
return organization.PlanType switch
{
var planType when PlanConstants.TeamsPlanTypes.Contains(planType) => PlanType.TeamsMonthly,
var planType when PlanConstants.EnterprisePlanTypes.Contains(planType) => PlanType.EnterpriseMonthly,
_ => throw new BillingException()
};
}
}

View File

@ -4,4 +4,8 @@
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,162 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Queries.Interfaces;
using Bit.Core.SecretsManager.Repositories;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies;
public class SecretAccessPoliciesUpdatesAuthorizationHandler : AuthorizationHandler<
SecretAccessPoliciesOperationRequirement,
SecretAccessPoliciesUpdates>
{
private readonly IAccessClientQuery _accessClientQuery;
private readonly ICurrentContext _currentContext;
private readonly ISameOrganizationQuery _sameOrganizationQuery;
private readonly ISecretRepository _secretRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
public SecretAccessPoliciesUpdatesAuthorizationHandler(ICurrentContext currentContext,
IAccessClientQuery accessClientQuery,
ISecretRepository secretRepository,
ISameOrganizationQuery sameOrganizationQuery,
IServiceAccountRepository serviceAccountRepository)
{
_currentContext = currentContext;
_accessClientQuery = accessClientQuery;
_sameOrganizationQuery = sameOrganizationQuery;
_serviceAccountRepository = serviceAccountRepository;
_secretRepository = secretRepository;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
SecretAccessPoliciesOperationRequirement requirement,
SecretAccessPoliciesUpdates resource)
{
if (!_currentContext.AccessSecretsManager(resource.OrganizationId))
{
return;
}
// Only users and admins should be able to manipulate access policies
var (accessClient, userId) =
await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck)
{
return;
}
switch (requirement)
{
case not null when requirement == SecretAccessPoliciesOperations.Updates:
await CanUpdateAsync(context, requirement, resource, accessClient,
userId);
break;
case not null when requirement == SecretAccessPoliciesOperations.Create:
await CanCreateAsync(context, requirement, resource, accessClient,
userId);
break;
default:
throw new ArgumentException("Unsupported operation requirement type provided.",
nameof(requirement));
}
}
private async Task CanUpdateAsync(AuthorizationHandlerContext context,
SecretAccessPoliciesOperationRequirement requirement,
SecretAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
var access = await _secretRepository
.AccessToSecretAsync(resource.SecretId, userId, accessClient);
if (!access.Write)
{
return;
}
if (!await GranteesInTheSameOrganizationAsync(resource))
{
return;
}
// Users can only create access policies for service accounts they have access to.
// User can delete and update any service account access policy if they have write access to the secret.
if (await HasAccessToTargetServiceAccountsAsync(resource, accessClient, userId))
{
context.Succeed(requirement);
}
}
private async Task CanCreateAsync(AuthorizationHandlerContext context,
SecretAccessPoliciesOperationRequirement requirement,
SecretAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
if (resource.UserAccessPolicyUpdates.Any(x => x.Operation != AccessPolicyOperation.Create) ||
resource.GroupAccessPolicyUpdates.Any(x => x.Operation != AccessPolicyOperation.Create) ||
resource.ServiceAccountAccessPolicyUpdates.Any(x => x.Operation != AccessPolicyOperation.Create))
{
return;
}
if (!await GranteesInTheSameOrganizationAsync(resource))
{
return;
}
// Users can only create access policies for service accounts they have access to.
if (await HasAccessToTargetServiceAccountsAsync(resource, accessClient, userId))
{
context.Succeed(requirement);
}
}
private async Task<bool> GranteesInTheSameOrganizationAsync(SecretAccessPoliciesUpdates resource)
{
var organizationUserIds = resource.UserAccessPolicyUpdates.Select(update =>
update.AccessPolicy.OrganizationUserId!.Value).ToList();
var groupIds = resource.GroupAccessPolicyUpdates.Select(update =>
update.AccessPolicy.GroupId!.Value).ToList();
var serviceAccountIds = resource.ServiceAccountAccessPolicyUpdates.Select(update =>
update.AccessPolicy.ServiceAccountId!.Value).ToList();
var usersInSameOrg = organizationUserIds.Count == 0 ||
await _sameOrganizationQuery.OrgUsersInTheSameOrgAsync(organizationUserIds,
resource.OrganizationId);
var groupsInSameOrg = groupIds.Count == 0 ||
await _sameOrganizationQuery.GroupsInTheSameOrgAsync(groupIds, resource.OrganizationId);
var serviceAccountsInSameOrg = serviceAccountIds.Count == 0 ||
await _serviceAccountRepository.ServiceAccountsAreInOrganizationAsync(
serviceAccountIds,
resource.OrganizationId);
return usersInSameOrg && groupsInSameOrg && serviceAccountsInSameOrg;
}
private async Task<bool> HasAccessToTargetServiceAccountsAsync(SecretAccessPoliciesUpdates resource,
AccessClientType accessClient, Guid userId)
{
var serviceAccountIdsToCheck = resource.ServiceAccountAccessPolicyUpdates
.Where(update => update.Operation == AccessPolicyOperation.Create).Select(update =>
update.AccessPolicy.ServiceAccountId!.Value).ToList();
if (serviceAccountIdsToCheck.Count == 0)
{
return true;
}
var serviceAccountsAccess =
await _serviceAccountRepository.AccessToServiceAccountsAsync(serviceAccountIdsToCheck, userId,
accessClient);
return serviceAccountsAccess.Count == serviceAccountIdsToCheck.Count &&
serviceAccountsAccess.All(a => a.Value.Write);
}
}

View File

@ -0,0 +1,63 @@
#nullable enable
using Bit.Core.Context;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Queries.Interfaces;
using Bit.Core.SecretsManager.Repositories;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.Secrets;
public class
BulkSecretAuthorizationHandler : AuthorizationHandler<BulkSecretOperationRequirement, IReadOnlyList<Secret>>
{
private readonly IAccessClientQuery _accessClientQuery;
private readonly ICurrentContext _currentContext;
private readonly ISecretRepository _secretRepository;
public BulkSecretAuthorizationHandler(ICurrentContext currentContext, IAccessClientQuery accessClientQuery,
ISecretRepository secretRepository)
{
_currentContext = currentContext;
_accessClientQuery = accessClientQuery;
_secretRepository = secretRepository;
}
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
BulkSecretOperationRequirement requirement,
IReadOnlyList<Secret> resources)
{
// Ensure all secrets belong to the same organization.
var organizationId = resources[0].OrganizationId;
if (resources.Any(secret => secret.OrganizationId != organizationId) ||
!_currentContext.AccessSecretsManager(organizationId))
{
return;
}
switch (requirement)
{
case not null when requirement == BulkSecretOperations.ReadAll:
await CanReadAllAsync(context, requirement, resources, organizationId);
break;
default:
throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement));
}
}
private async Task CanReadAllAsync(AuthorizationHandlerContext context,
BulkSecretOperationRequirement requirement, IReadOnlyList<Secret> resources, Guid organizationId)
{
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, organizationId);
var secretsAccess =
await _secretRepository.AccessToSecretsAsync(resources.Select(s => s.Id), userId, accessClient);
if (secretsAccess.Count == resources.Count &&
secretsAccess.All(a => a.Value.Read))
{
context.Succeed(requirement);
}
}
}

View File

@ -47,6 +47,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
case not null when requirement == SecretOperations.Delete:
await CanDeleteSecretAsync(context, requirement, resource);
break;
case not null when requirement == SecretOperations.ReadAccessPolicies:
await CanReadAccessPoliciesAsync(context, requirement, resource);
break;
default:
throw new ArgumentException("Unsupported operation requirement type provided.", nameof(requirement));
}
@ -106,9 +109,9 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
{
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
// All projects should be apart of the same organization
// All projects should be in the same organization
if (resource.Projects != null
&& resource.Projects.Any()
&& resource.Projects.Count != 0
&& !await _projectRepository.ProjectsAreInOrganization(resource.Projects.Select(p => p.Id).ToList(),
resource.OrganizationId))
{
@ -152,10 +155,42 @@ public class SecretAuthorizationHandler : AuthorizationHandler<SecretOperationRe
}
}
private async Task CanReadAccessPoliciesAsync(AuthorizationHandlerContext context,
SecretOperationRequirement requirement, Secret resource)
{
var (accessClient, userId) = await _accessClientQuery.GetAccessClientAsync(context.User, resource.OrganizationId);
// Only users and admins can read access policies
if (accessClient != AccessClientType.User && accessClient != AccessClientType.NoAccessCheck)
{
return;
}
var access = await _secretRepository.AccessToSecretAsync(resource.Id, userId, accessClient);
if (access.Write)
{
context.Succeed(requirement);
}
}
private async Task<bool> GetAccessToUpdateSecretAsync(Secret resource, Guid userId, AccessClientType accessClient)
{
var newProject = resource.Projects?.FirstOrDefault();
// Request was to remove all projects from the secret. This is not allowed for non admin users.
if (resource.Projects?.Count == 0)
{
return false;
}
var access = (await _secretRepository.AccessToSecretAsync(resource.Id, userId, accessClient)).Write;
// No project mapping changes requested, return secret access.
if (resource.Projects == null)
{
return access;
}
var newProject = resource.Projects?.FirstOrDefault();
var accessToNew = newProject != null &&
(await _projectRepository.AccessToProjectAsync(newProject.Id, userId, accessClient))
.Write;

View File

@ -28,16 +28,16 @@ public class CreateProjectCommand : ICreateProjectCommand
_currentContext = currentContext;
}
public async Task<Project> CreateAsync(Project project, Guid id, ClientType clientType)
public async Task<Project> CreateAsync(Project project, Guid id, IdentityClientType identityClientType)
{
if (clientType != ClientType.User && clientType != ClientType.ServiceAccount)
if (identityClientType != IdentityClientType.User && identityClientType != IdentityClientType.ServiceAccount)
{
throw new NotFoundException();
}
var createdProject = await _projectRepository.CreateAsync(project);
if (clientType == ClientType.User)
if (identityClientType == IdentityClientType.User)
{
var orgUser = await _organizationUserRepository.GetByOrganizationAsync(createdProject.OrganizationId, id);
@ -52,7 +52,7 @@ public class CreateProjectCommand : ICreateProjectCommand
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
}
else if (clientType == ClientType.ServiceAccount)
else if (identityClientType == IdentityClientType.ServiceAccount)
{
var serviceAccountProjectAccessPolicy = new ServiceAccountProjectAccessPolicy()
{

View File

@ -0,0 +1,34 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.SecretsManager.Commands.Requests.Interfaces;
using Bit.Core.Services;
namespace Bit.Commercial.Core.SecretsManager.Commands.Requests;
public class RequestSMAccessCommand : IRequestSMAccessCommand
{
private readonly IMailService _mailService;
public RequestSMAccessCommand(
IMailService mailService)
{
_mailService = mailService;
}
public async Task SendRequestAccessToSM(Organization organization, ICollection<OrganizationUserUserDetails> orgUsers, User user, string emailContent)
{
var emailList = orgUsers.Where(o => o.Type <= OrganizationUserType.Admin)
.Select(a => a.Email).Distinct().ToList();
if (!emailList.Any())
{
throw new BadRequestException("The organization is in an invalid state. Please contact Customer Support.");
}
var userRequestingAccess = user.Name ?? user.Email;
await _mailService.SendRequestSMAccessToAdminEmailAsync(emailList, organization.Name, userRequestingAccess, emailContent);
}
}

View File

@ -1,5 +1,7 @@
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
#nullable enable
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
@ -13,8 +15,8 @@ public class CreateSecretCommand : ICreateSecretCommand
_secretRepository = secretRepository;
}
public async Task<Secret> CreateAsync(Secret secret)
public async Task<Secret> CreateAsync(Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates)
{
return await _secretRepository.CreateAsync(secret);
return await _secretRepository.CreateAsync(secret, accessPoliciesUpdates);
}
}

View File

@ -1,6 +1,7 @@
using Bit.Core.Exceptions;
#nullable enable
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
@ -14,21 +15,8 @@ public class UpdateSecretCommand : IUpdateSecretCommand
_secretRepository = secretRepository;
}
public async Task<Secret> UpdateAsync(Secret updatedSecret)
public async Task<Secret> UpdateAsync(Secret secret, SecretAccessPoliciesUpdates? accessPolicyUpdates)
{
var secret = await _secretRepository.GetByIdAsync(updatedSecret.Id);
if (secret == null)
{
throw new NotFoundException();
}
secret.Key = updatedSecret.Key;
secret.Value = updatedSecret.Value;
secret.Note = updatedSecret.Note;
secret.Projects = updatedSecret.Projects;
secret.RevisionDate = DateTime.UtcNow;
await _secretRepository.UpdateAsync(secret);
return secret;
return await _secretRepository.UpdateAsync(secret, accessPolicyUpdates);
}
}

View File

@ -21,7 +21,7 @@ public class AccessClientQuery : IAccessClientQuery
ClaimsPrincipal claimsPrincipal, Guid organizationId)
{
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.IdentityClientType, orgAdmin);
var userId = _userService.GetProperUserId(claimsPrincipal).Value;
return (accessClient, userId);
}

View File

@ -0,0 +1,24 @@
#nullable enable
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Queries.AccessPolicies;
public class SecretAccessPoliciesUpdatesQuery : ISecretAccessPoliciesUpdatesQuery
{
private readonly IAccessPolicyRepository _accessPolicyRepository;
public SecretAccessPoliciesUpdatesQuery(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}
public async Task<SecretAccessPoliciesUpdates> GetAsync(SecretAccessPolicies accessPolicies, Guid userId)
{
var currentPolicies = await _accessPolicyRepository.GetSecretAccessPoliciesAsync(accessPolicies.SecretId, userId);
return currentPolicies == null ? new SecretAccessPoliciesUpdates(accessPolicies) : currentPolicies.GetPolicyUpdates(accessPolicies);
}
}

View File

@ -1,4 +1,4 @@
using Bit.Core.Enums;
using Bit.Core.Billing.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.SecretsManager.Queries.Projects.Interfaces;
@ -28,6 +28,7 @@ public class MaxProjectsQuery : IMaxProjectsQuery
throw new NotFoundException();
}
// TODO: PRICING -> https://bitwarden.atlassian.net/browse/PM-17122
var plan = StaticStore.GetPlan(org.PlanType);
if (plan?.SecretsManager == null)
{

View File

@ -6,6 +6,7 @@ using Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
using Bit.Commercial.Core.SecretsManager.Commands.AccessTokens;
using Bit.Commercial.Core.SecretsManager.Commands.Porting;
using Bit.Commercial.Core.SecretsManager.Commands.Projects;
using Bit.Commercial.Core.SecretsManager.Commands.Requests;
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
using Bit.Commercial.Core.SecretsManager.Commands.Trash;
@ -18,6 +19,7 @@ using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces;
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Commands.Requests.Interfaces;
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Commands.Trash.Interfaces;
@ -42,17 +44,21 @@ public static class SecretsManagerCollectionExtensions
services.AddScoped<IAuthorizationHandler, ServiceAccountPeopleAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ServiceAccountGrantedPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, ProjectServiceAccountsAccessPoliciesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, SecretAccessPoliciesUpdatesAuthorizationHandler>();
services.AddScoped<IAuthorizationHandler, BulkSecretAuthorizationHandler>();
services.AddScoped<IAccessClientQuery, AccessClientQuery>();
services.AddScoped<IMaxProjectsQuery, MaxProjectsQuery>();
services.AddScoped<ISameOrganizationQuery, SameOrganizationQuery>();
services.AddScoped<IServiceAccountSecretsDetailsQuery, ServiceAccountSecretsDetailsQuery>();
services.AddScoped<IServiceAccountGrantedPolicyUpdatesQuery, ServiceAccountGrantedPolicyUpdatesQuery>();
services.AddScoped<ISecretAccessPoliciesUpdatesQuery, SecretAccessPoliciesUpdatesQuery>();
services.AddScoped<ISecretsSyncQuery, SecretsSyncQuery>();
services.AddScoped<IProjectServiceAccountsAccessPoliciesUpdatesQuery, ProjectServiceAccountsAccessPoliciesUpdatesQuery>();
services.AddScoped<ICreateSecretCommand, CreateSecretCommand>();
services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>();
services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>();
services.AddScoped<ICreateProjectCommand, CreateProjectCommand>();
services.AddScoped<IRequestSMAccessCommand, RequestSMAccessCommand>();
services.AddScoped<IUpdateProjectCommand, UpdateProjectCommand>();
services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>();
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();

View File

@ -1,7 +1,9 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Commercial.Core.AdminConsole.Services;
using Bit.Commercial.Core.Billing;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.Utilities;
@ -13,5 +15,6 @@ public static class ServiceCollectionExtensions
services.AddScoped<IProviderService, ProviderService>();
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
services.AddTransient<IProviderBillingService, ProviderBillingService>();
}
}

View File

@ -20,7 +20,8 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
{
}
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(
List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
@ -39,6 +40,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy:
{
var entity =
Mapper.Map<UserSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy:
{
var entity =
@ -52,6 +60,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupServiceAccountAccessPolicy>(accessPolicy);
@ -65,6 +79,13 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
serviceAccountIds.Add(entity.ServiceAccountId!.Value);
break;
}
case Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy:
{
var entity = Mapper.Map<ServiceAccountSecretAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
serviceAccountIds.Add(entity.ServiceAccountId!.Value);
break;
}
}
}
@ -395,6 +416,42 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
await transaction.CommitAsync();
}
public async Task<SecretAccessPolicies?> GetSecretAccessPoliciesAsync(
Guid secretId,
Guid userId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var entities = await dbContext.AccessPolicies.Where(ap =>
((UserSecretAccessPolicy)ap).GrantedSecretId == secretId ||
((GroupSecretAccessPolicy)ap).GrantedSecretId == secretId ||
((ServiceAccountSecretAccessPolicy)ap).GrantedSecretId == secretId)
.Include(ap => ((UserSecretAccessPolicy)ap).OrganizationUser.User)
.Include(ap => ((GroupSecretAccessPolicy)ap).Group)
.Include(ap => ((ServiceAccountSecretAccessPolicy)ap).ServiceAccount)
.Select(ap => new
{
ap,
CurrentUserInGroup = ap is GroupSecretAccessPolicy &&
((GroupSecretAccessPolicy)ap).Group.GroupUsers.Any(g =>
g.OrganizationUser.UserId == userId)
})
.ToListAsync();
if (entities.Count == 0)
{
return null;
}
var organizationId = await dbContext.Secret.Where(s => s.Id == secretId)
.Select(s => s.OrganizationId)
.SingleAsync();
return new SecretAccessPolicies(secretId, organizationId,
entities.Select(e => MapToCore(e.ap, e.CurrentUserInGroup)).ToList());
}
private static async Task UpsertPeoplePoliciesAsync(DatabaseContext dbContext,
List<BaseAccessPolicy> policies, IReadOnlyCollection<AccessPolicy> userPolicyEntities,
IReadOnlyCollection<AccessPolicy> groupPolicyEntities)
@ -466,13 +523,17 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
baseAccessPolicyEntity switch
{
UserProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserProjectAccessPolicy>(ap),
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
ServiceAccountProjectAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
UserSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserSecretAccessPolicy>(ap),
UserServiceAccountAccessPolicy ap =>
Mapper.Map<Core.SecretsManager.Entities.UserServiceAccountAccessPolicy>(ap),
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
GroupSecretAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap),
GroupServiceAccountAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap),
ServiceAccountProjectAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
ServiceAccountSecretAccessPolicy ap => Mapper
.Map<Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy>(ap),
_ => throw new ArgumentException("Unsupported access policy type")
};
@ -482,20 +543,26 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
{
Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy => Mapper.Map<UserProjectAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy => Mapper
.Map<UserServiceAccountAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy => Mapper.Map<GroupProjectAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy => Mapper
.Map<GroupServiceAccountAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountProjectAccessPolicy>(accessPolicy),
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
_ => throw new ArgumentException("Unsupported access policy type")
};
}
private Core.SecretsManager.Entities.BaseAccessPolicy MapToCore(
BaseAccessPolicy baseAccessPolicyEntity, bool currentUserInGroup)
BaseAccessPolicy baseAccessPolicyEntity, bool currentUserInGroup)
{
switch (baseAccessPolicyEntity)
{
@ -505,6 +572,12 @@ public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPoli
mapped.CurrentUserInGroup = currentUserInGroup;
return mapped;
}
case GroupSecretAccessPolicy ap:
{
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupSecretAccessPolicy>(ap);
mapped.CurrentUserInGroup = currentUserInGroup;
return mapped;
}
case GroupServiceAccountAccessPolicy ap:
{
var mapped = Mapper.Map<Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy>(ap);

View File

@ -16,7 +16,7 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
: base(serviceScopeFactory, mapper, db => db.Project)
{ }
public override async Task<Core.SecretsManager.Entities.Project> GetByIdAsync(Guid id)
public override async Task<Core.SecretsManager.Entities.Project?> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
@ -169,6 +169,58 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
return await accessQuery.ToDictionaryAsync(pa => pa.Id, pa => (pa.Read, pa.Write));
}
public async Task<int> GetProjectCountByOrganizationIdAsync(Guid organizationId, Guid userId,
AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
return await query.CountAsync();
}
public async Task<ProjectCounts> GetProjectCountsByIdAsync(Guid projectId, Guid userId, AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project.Where(p => p.Id == projectId && p.DeletedDate == null);
var queryReadAccess = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var queryWriteAccess = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasWriteAccessToProject(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var secretsQuery = queryReadAccess.Select(project => project.Secrets.Count(s => s.DeletedDate == null));
var projectCountsQuery = queryWriteAccess.Select(project => new ProjectCounts
{
People = project.UserAccessPolicies.Count + project.GroupAccessPolicies.Count,
ServiceAccounts = project.ServiceAccountAccessPolicies.Count
});
var secrets = await secretsQuery.FirstOrDefaultAsync();
var projectCounts = await projectCountsQuery.FirstOrDefaultAsync() ?? new ProjectCounts { Secrets = 0, People = 0, ServiceAccounts = 0 };
projectCounts.Secrets = secrets;
return projectCounts;
}
private record ProjectAccess(Guid Id, bool Read, bool Write);
private static IQueryable<ProjectAccess> BuildProjectAccessQuery(IQueryable<Project> projectQuery, Guid userId,

View File

@ -1,7 +1,9 @@
using System.Linq.Expressions;
using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework;
using Bit.Infrastructure.EntityFramework.Repositories;
@ -17,7 +19,7 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
: base(serviceScopeFactory, mapper, db => db.Secret)
{ }
public override async Task<Core.SecretsManager.Entities.Secret> GetByIdAsync(Guid id)
public override async Task<Core.SecretsManager.Entities.Secret?> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
@ -136,8 +138,8 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
return await secrets.ToListAsync();
}
public override async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
Core.SecretsManager.Entities.Secret secret)
public async Task<Core.SecretsManager.Entities.Secret> CreateAsync(
Core.SecretsManager.Entities.Secret secret, SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
@ -158,13 +160,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
}
await dbContext.AddAsync(entity);
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
secret.Id = entity.Id;
return secret;
}
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret)
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret,
SecretAccessPoliciesUpdates? accessPoliciesUpdates = null)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
@ -173,36 +176,30 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
var entity = await dbContext.Secret
.Include(s => s.Projects)
.Include(s => s.UserAccessPolicies)
.Include(s => s.GroupAccessPolicies)
.Include(s => s.ServiceAccountAccessPolicies)
.FirstAsync(s => s.Id == secret.Id);
var projectsToRemove = entity.Projects.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id)).ToList();
var projectsToAdd = mappedEntity.Projects.Where(p => entity.Projects.All(ep => ep.Id != p.Id)).ToList();
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
foreach (var p in projectsToRemove)
if (secret.Projects != null)
{
entity.Projects.Remove(p);
entity = await UpdateProjectMappingAsync(dbContext, entity, mappedEntity);
}
foreach (var project in projectsToAdd)
if (accessPoliciesUpdates != null)
{
var p = dbContext.AttachToOrGet<Project>(x => x.Id == project.Id, () => project);
entity.Projects.Add(p);
}
var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList();
if (projectIds.Count > 0)
{
await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds);
await UpdateSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
}
await UpdateServiceAccountRevisionsBySecretIdsAsync(dbContext, [entity.Id]);
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
await dbContext.SaveChangesAsync();
await transaction.CommitAsync();
return secret;
return Mapper.Map<Core.SecretsManager.Entities.Secret>(entity);
}
public async Task SoftDeleteManyByIdAsync(IEnumerable<Guid> ids)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
@ -289,41 +286,35 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
public async Task<(bool Read, bool Write)> AccessToSecretAsync(Guid id, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var secret = dbContext.Secret
.Where(s => s.Id == id);
var query = accessType switch
{
AccessClientType.NoAccessCheck => secret.Select(_ => new { Read = true, Write = true }),
AccessClientType.User => secret.Select(s => new
{
Read = s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
Write = s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))),
}),
AccessClientType.ServiceAccount => secret.Select(s => new
{
Read = s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == userId && ap.Read)),
Write = s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == userId && ap.Write)),
}),
_ => secret.Select(_ => new { Read = false, Write = false }),
};
var query = BuildSecretAccessQuery(secret, userId, accessType);
var policy = await query.FirstOrDefaultAsync();
return policy == null ? (false, false) : (policy.Read, policy.Write);
}
public async Task<Dictionary<Guid, (bool Read, bool Write)>> AccessToSecretsAsync(
IEnumerable<Guid> ids,
Guid userId,
AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var secrets = dbContext.Secret
.Where(s => ids.Contains(s.Id));
var accessQuery = BuildSecretAccessQuery(secrets, userId, accessType);
return await accessQuery.ToDictionaryAsync(sa => sa.Id, sa => (sa.Read, sa.Write));
}
public async Task EmptyTrash(DateTime currentDate, uint deleteAfterThisNumberOfDays)
{
using var scope = ServiceScopeFactory.CreateScope();
@ -334,6 +325,23 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
await dbContext.SaveChangesAsync();
}
public async Task<int> GetSecretsCountByOrganizationIdAsync(Guid organizationId, Guid userId,
AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Secret.Where(s => s.OrganizationId == organizationId && s.DeletedDate == null);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToSecret(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
return await query.CountAsync();
}
private IQueryable<SecretPermissionDetails> SecretToPermissionDetails(IQueryable<Secret> query, Guid userId, AccessClientType accessType)
{
var secrets = accessType switch
@ -361,19 +369,27 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
private Expression<Func<Secret, SecretPermissionDetails>> SecretToPermissionsUser(Guid userId, bool read) =>
s => new SecretPermissionDetails
{
Secret = Mapper.Map<Bit.Core.SecretsManager.Entities.Secret>(s),
Secret = Mapper.Map<Core.SecretsManager.Entities.Secret>(s),
Read = read,
Write = s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write))),
Write =
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write)))
};
private static Expression<Func<Secret, bool>> ServiceAccountHasReadAccessToSecret(Guid serviceAccountId) => s =>
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == serviceAccountId && ap.Read) ||
s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read));
private static Expression<Func<Secret, bool>> UserHasReadAccessToSecret(Guid userId) => s =>
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.UserId == userId && ap.Read)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.UserId == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap =>
@ -434,4 +450,197 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
.ExecuteUpdateAsync(setters => setters.SetProperty(b => b.RevisionDate, utcNow));
}
}
private static IQueryable<SecretAccess> BuildSecretAccessQuery(IQueryable<Secret> secrets, Guid accessClientId,
AccessClientType accessType) =>
accessType switch
{
AccessClientType.NoAccessCheck => secrets.Select(s => new SecretAccess(s.Id, true, true)),
AccessClientType.User => secrets.Select(s => new SecretAccess(
s.Id,
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Read) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Read)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Read) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Read))),
s.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Write) ||
s.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Write)) ||
s.Projects.Any(p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == accessClientId && ap.Write) ||
p.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == accessClientId && ap.Write)))
)),
AccessClientType.ServiceAccount => secrets.Select(s => new SecretAccess(
s.Id,
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Read) ||
s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Read)),
s.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Write) ||
s.Projects.Any(p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccountId == accessClientId && ap.Write))
)),
_ => secrets.Select(s => new SecretAccess(s.Id, false, false))
};
private static async Task<Secret> UpdateProjectMappingAsync(DatabaseContext dbContext, Secret currentEntity, Secret updatedEntity)
{
var projectsToRemove = currentEntity.Projects.Where(p => updatedEntity.Projects.All(mp => mp.Id != p.Id)).ToList();
var projectsToAdd = updatedEntity.Projects.Where(p => currentEntity.Projects.All(ep => ep.Id != p.Id)).ToList();
foreach (var p in projectsToRemove)
{
currentEntity.Projects.Remove(p);
}
foreach (var project in projectsToAdd)
{
var p = dbContext.AttachToOrGet<Project>(x => x.Id == project.Id, () => project);
currentEntity.Projects.Add(p);
}
var projectIds = projectsToRemove.Select(p => p.Id).Concat(projectsToAdd.Select(p => p.Id)).ToList();
if (projectIds.Count > 0)
{
await UpdateServiceAccountRevisionsByProjectIdsAsync(dbContext, projectIds);
}
return currentEntity;
}
private static async Task DeleteSecretAccessPoliciesAsync(DatabaseContext dbContext, Secret entity,
SecretAccessPoliciesUpdates accessPoliciesUpdates)
{
var userAccessPoliciesIdsToDelete = entity.UserAccessPolicies.Where(uap => accessPoliciesUpdates
.UserAccessPolicyUpdates
.Any(apu => apu.Operation == AccessPolicyOperation.Delete &&
apu.AccessPolicy.OrganizationUserId == uap.OrganizationUserId))
.Select(uap => uap.Id)
.ToList();
var groupAccessPoliciesIdsToDelete = entity.GroupAccessPolicies.Where(gap => accessPoliciesUpdates
.GroupAccessPolicyUpdates
.Any(apu => apu.Operation == AccessPolicyOperation.Delete && apu.AccessPolicy.GroupId == gap.GroupId))
.Select(gap => gap.Id)
.ToList();
var serviceAccountAccessPoliciesIdsToDelete = entity.ServiceAccountAccessPolicies.Where(gap =>
accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates
.Any(apu => apu.Operation == AccessPolicyOperation.Delete &&
apu.AccessPolicy.ServiceAccountId == gap.ServiceAccountId))
.Select(sap => sap.Id)
.ToList();
var accessPoliciesIdsToDelete = userAccessPoliciesIdsToDelete
.Concat(groupAccessPoliciesIdsToDelete)
.Concat(serviceAccountAccessPoliciesIdsToDelete)
.ToList();
await dbContext.AccessPolicies
.Where(ap => accessPoliciesIdsToDelete.Contains(ap.Id))
.ExecuteDeleteAsync();
}
private static async Task UpsertSecretAccessPolicyAsync(DatabaseContext dbContext, BaseAccessPolicy updatedEntity,
AccessPolicyOperation accessPolicyOperation, AccessPolicy? currentEntity, DateTime currentDate)
{
switch (accessPolicyOperation)
{
case AccessPolicyOperation.Create when currentEntity == null:
updatedEntity.SetNewId();
await dbContext.AddAsync(updatedEntity);
break;
case AccessPolicyOperation.Update when currentEntity != null:
dbContext.AccessPolicies.Attach(currentEntity);
currentEntity.Read = updatedEntity.Read;
currentEntity.Write = updatedEntity.Write;
currentEntity.RevisionDate = currentDate;
break;
default:
throw new InvalidOperationException("Policy updates failed due to unexpected state.");
}
}
private async Task UpsertSecretAccessPoliciesAsync(DatabaseContext dbContext,
Secret entity,
SecretAccessPoliciesUpdates policyUpdates)
{
var currentDate = DateTime.UtcNow;
foreach (var policyUpdate in policyUpdates.UserAccessPolicyUpdates.Where(apu =>
apu.Operation != AccessPolicyOperation.Delete))
{
var currentEntity = entity.UserAccessPolicies?.FirstOrDefault(e =>
e.OrganizationUserId == policyUpdate.AccessPolicy.OrganizationUserId!.Value);
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
policyUpdate.Operation,
currentEntity,
currentDate);
}
foreach (var policyUpdate in policyUpdates.GroupAccessPolicyUpdates.Where(apu =>
apu.Operation != AccessPolicyOperation.Delete))
{
var currentEntity = entity.GroupAccessPolicies?.FirstOrDefault(e =>
e.GroupId == policyUpdate.AccessPolicy.GroupId!.Value);
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
policyUpdate.Operation,
currentEntity,
currentDate);
}
foreach (var policyUpdate in policyUpdates.ServiceAccountAccessPolicyUpdates.Where(apu =>
apu.Operation != AccessPolicyOperation.Delete))
{
var currentEntity = entity.ServiceAccountAccessPolicies?.FirstOrDefault(e =>
e.ServiceAccountId == policyUpdate.AccessPolicy.ServiceAccountId!.Value);
await UpsertSecretAccessPolicyAsync(dbContext, MapToEntity(policyUpdate.AccessPolicy),
policyUpdate.Operation,
currentEntity,
currentDate);
}
}
private async Task UpdateSecretAccessPoliciesAsync(DatabaseContext dbContext,
Secret entity,
SecretAccessPoliciesUpdates? accessPoliciesUpdates)
{
if (accessPoliciesUpdates == null || !accessPoliciesUpdates.HasUpdates())
{
return;
}
if ((entity.UserAccessPolicies != null && entity.UserAccessPolicies.Count != 0) ||
(entity.GroupAccessPolicies != null && entity.GroupAccessPolicies.Count != 0) ||
(entity.ServiceAccountAccessPolicies != null && entity.ServiceAccountAccessPolicies.Count != 0))
{
await DeleteSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
}
await UpsertSecretAccessPoliciesAsync(dbContext, entity, accessPoliciesUpdates);
await UpdateServiceAccountRevisionsAsync(dbContext,
accessPoliciesUpdates.ServiceAccountAccessPolicyUpdates
.Select(sap => sap.AccessPolicy.ServiceAccountId!.Value).ToList());
}
private BaseAccessPolicy MapToEntity(Core.SecretsManager.Entities.BaseAccessPolicy baseAccessPolicy) =>
baseAccessPolicy switch
{
Core.SecretsManager.Entities.UserSecretAccessPolicy accessPolicy => Mapper.Map<UserSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.GroupSecretAccessPolicy accessPolicy => Mapper.Map<GroupSecretAccessPolicy>(
accessPolicy),
Core.SecretsManager.Entities.ServiceAccountSecretAccessPolicy accessPolicy => Mapper
.Map<ServiceAccountSecretAccessPolicy>(accessPolicy),
_ => throw new ArgumentException("Unsupported access policy type")
};
private record SecretAccess(Guid Id, bool Read, bool Write);
}

View File

@ -125,6 +125,48 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
}
}
public async Task<int> GetServiceAccountCountByOrganizationIdAsync(Guid organizationId, Guid userId,
AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.ServiceAccount.Where(sa => sa.OrganizationId == organizationId);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
return await query.CountAsync();
}
public async Task<ServiceAccountCounts> GetServiceAccountCountsByIdAsync(Guid serviceAccountId, Guid userId,
AccessClientType accessType)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.ServiceAccount.Where(sa => sa.Id == serviceAccountId);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var serviceAccountCountsQuery = query.Select(serviceAccount => new ServiceAccountCounts
{
Projects = serviceAccount.ProjectAccessPolicies.Count,
People = serviceAccount.UserAccessPolicies.Count + serviceAccount.GroupAccessPolicies.Count,
AccessTokens = serviceAccount.ApiKeys.Count
});
var serviceAccountCounts = await serviceAccountCountsQuery.FirstOrDefaultAsync();
return serviceAccountCounts ?? new ServiceAccountCounts { Projects = 0, People = 0, AccessTokens = 0 };
}
public async Task<bool> ServiceAccountsAreInOrganizationAsync(List<Guid> serviceAccountIds, Guid organizationId)
{
await using var scope = ServiceScopeFactory.CreateAsyncScope();
@ -135,38 +177,37 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
}
public async Task<IEnumerable<ServiceAccountSecretsDetails>> GetManyByOrganizationIdWithSecretsDetailsAsync(
Guid organizationId, Guid userId, AccessClientType accessType)
Guid organizationId, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
await using var scope = ServiceScopeFactory.CreateAsyncScope();
var dbContext = GetDatabaseContext(scope);
var query = from sa in dbContext.ServiceAccount
join ap in dbContext.ServiceAccountProjectAccessPolicy
on sa.Id equals ap.ServiceAccountId into grouping
from ap in grouping.DefaultIfEmpty()
where sa.OrganizationId == organizationId
select new
{
ServiceAccount = sa,
AccessToSecrets = ap.GrantedProject.Secrets.Count(s => s.DeletedDate == null)
};
query = accessType switch
var serviceAccountQuery = dbContext.ServiceAccount.Where(c => c.OrganizationId == organizationId);
serviceAccountQuery = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(c =>
c.ServiceAccount.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
c.ServiceAccount.GroupAccessPolicies.Any(ap =>
AccessClientType.NoAccessCheck => serviceAccountQuery,
AccessClientType.User => serviceAccountQuery.Where(c =>
c.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
c.GroupAccessPolicies.Any(ap =>
ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read))),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var results = (await query.ToListAsync())
var projectSecretsAccessQuery = BuildProjectSecretsAccessQuery(dbContext, serviceAccountQuery);
var directSecretAccessQuery = BuildDirectSecretAccessQuery(dbContext, serviceAccountQuery);
var projectSecretsAccessResults = await projectSecretsAccessQuery.ToListAsync();
var directSecretAccessResults = await directSecretAccessQuery.ToListAsync();
var applicableDirectSecretAccessResults = FilterDirectSecretAccessResults(projectSecretsAccessResults, directSecretAccessResults);
var results = projectSecretsAccessResults.Concat(applicableDirectSecretAccessResults)
.GroupBy(g => g.ServiceAccount)
.Select(g =>
new ServiceAccountSecretsDetails
{
ServiceAccount = Mapper.Map<Core.SecretsManager.Entities.ServiceAccount>(g.Key),
AccessToSecrets = g.Sum(x => x.AccessToSecrets),
AccessToSecrets = g.Sum(x => x.SecretIds.Count())
}).OrderBy(c => c.ServiceAccount.RevisionDate).ToList();
return results;
@ -200,4 +241,46 @@ public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.
private static Expression<Func<ServiceAccount, bool>> UserHasWriteAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write));
private static IQueryable<ServiceAccountSecretsAccess> BuildProjectSecretsAccessQuery(DatabaseContext dbContext,
IQueryable<ServiceAccount> serviceAccountQuery) =>
from sa in serviceAccountQuery
join ap in dbContext.ServiceAccountProjectAccessPolicy
on sa.Id equals ap.ServiceAccountId into grouping
from ap in grouping.DefaultIfEmpty()
select new ServiceAccountSecretsAccess
(
sa, ap.GrantedProject.Secrets.Where(s => s.DeletedDate == null).Select(s => s.Id)
);
private static IQueryable<ServiceAccountSecretsAccess> BuildDirectSecretAccessQuery(
DatabaseContext dbContext,
IQueryable<ServiceAccount> serviceAccountQuery) =>
from sa in serviceAccountQuery
join ap in dbContext.ServiceAccountSecretAccessPolicy
on sa.Id equals ap.ServiceAccountId into grouping
from ap in grouping.DefaultIfEmpty()
where ap.GrantedSecret.DeletedDate == null &&
ap.GrantedSecretId != null
select new ServiceAccountSecretsAccess(sa,
new List<Guid> { ap.GrantedSecretId!.Value });
private static List<ServiceAccountSecretsAccess> FilterDirectSecretAccessResults(
List<ServiceAccountSecretsAccess> projectSecretsAccessResults,
List<ServiceAccountSecretsAccess> directSecretAccessResults) =>
directSecretAccessResults.Where(directSecretAccessResult =>
{
var serviceAccountId = directSecretAccessResult.ServiceAccount.Id;
var secretId = directSecretAccessResult.SecretIds.FirstOrDefault();
if (secretId == Guid.Empty)
{
return false;
}
return !projectSecretsAccessResults
.Where(x => x.ServiceAccount.Id == serviceAccountId)
.Any(x => x.SecretIds.Contains(secretId));
}).ToList();
private record ServiceAccountSecretsAccess(ServiceAccount ServiceAccount, IEnumerable<Guid> SecretIds);
}

View File

@ -4,6 +4,7 @@ using Bit.Core.AdminConsole.Models.OrganizationConnectionConfigs;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Settings;
using Bit.Core.Utilities;
namespace Bit.Scim.Context;
@ -11,6 +12,32 @@ public class ScimContext : IScimContext
{
private bool _builtHttpContext;
// See IP list from Ping in docs: https://support.pingidentity.com/s/article/PingOne-IP-Addresses
private static readonly HashSet<string> _pingIpAddresses =
[
"18.217.152.87",
"52.14.10.143",
"13.58.49.148",
"34.211.92.81",
"54.214.158.219",
"34.218.98.164",
"15.223.133.47",
"3.97.84.38",
"15.223.19.71",
"3.97.98.120",
"52.60.115.173",
"3.97.202.223",
"18.184.65.93",
"52.57.244.92",
"18.195.7.252",
"108.128.67.71",
"34.246.158.102",
"108.128.250.27",
"52.63.103.92",
"13.54.131.18",
"52.62.204.36"
];
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
public ScimConfig ScimConfiguration { get; set; }
public Guid? OrganizationId { get; set; }
@ -55,10 +82,18 @@ public class ScimContext : IScimContext
RequestScimProvider = ScimProviderType.Okta;
}
}
if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.ContainsKey("Adscimversion"))
{
RequestScimProvider = ScimProviderType.AzureAd;
}
var ipAddress = CoreHelpers.GetIpAddress(httpContext, globalSettings);
if (RequestScimProvider == ScimProviderType.Default &&
_pingIpAddresses.Contains(ipAddress))
{
RequestScimProvider = ScimProviderType.Ping;
}
}
}

View File

@ -1,8 +1,10 @@
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
@ -22,9 +24,10 @@ public class GroupsController : Controller
private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPatchGroupCommandvNext _patchGroupCommandvNext;
private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand;
private readonly ILogger<GroupsController> _logger;
private readonly IFeatureService _featureService;
public GroupsController(
IGroupRepository groupRepository,
@ -32,18 +35,21 @@ public class GroupsController : Controller
IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand,
IPatchGroupCommandvNext patchGroupCommandvNext,
IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand,
ILogger<GroupsController> logger)
IFeatureService featureService
)
{
_groupRepository = groupRepository;
_organizationRepository = organizationRepository;
_getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand;
_patchGroupCommandvNext = patchGroupCommandvNext;
_postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand;
_logger = logger;
_featureService = featureService;
}
[HttpGet("{id}")]
@ -97,8 +103,21 @@ public class GroupsController : Controller
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
if (_featureService.IsEnabled(FeatureFlagKeys.ShortcutDuplicatePatchRequests))
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
await _patchGroupCommandvNext.PatchGroupAsync(group, model);
return new NoContentResult();
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
return new NoContentResult();
}

View File

@ -17,30 +17,27 @@ namespace Bit.Scim.Controllers.v2;
[ExceptionHandlerFilter]
public class UsersController : Controller
{
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IGetUsersListQuery _getUsersListQuery;
private readonly IDeleteOrganizationUserCommand _deleteOrganizationUserCommand;
private readonly IRemoveOrganizationUserCommand _removeOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand;
private readonly ILogger<UsersController> _logger;
public UsersController(
IUserService userService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IGetUsersListQuery getUsersListQuery,
IDeleteOrganizationUserCommand deleteOrganizationUserCommand,
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand,
ILogger<UsersController> logger)
{
_userService = userService;
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_getUsersListQuery = getUsersListQuery;
_deleteOrganizationUserCommand = deleteOrganizationUserCommand;
_removeOrganizationUserCommand = removeOrganizationUserCommand;
_patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand;
_logger = logger;
@ -60,17 +57,15 @@ public class UsersController : Controller
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
[FromQuery] GetUsersQueryParamModel model)
{
var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, filter, count, startIndex);
var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, model);
var scimListResponseModel = new ScimListResponseModel<ScimUserResponseModel>
{
Resources = usersListQueryResult.userList.Select(u => new ScimUserResponseModel(u)).ToList(),
ItemsPerPage = count.GetValueOrDefault(usersListQueryResult.userList.Count()),
ItemsPerPage = model.Count,
TotalResults = usersListQueryResult.totalResults,
StartIndex = startIndex.GetValueOrDefault(1),
StartIndex = model.StartIndex,
};
return Ok(scimListResponseModel);
}
@ -98,7 +93,7 @@ public class UsersController : Controller
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
{
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
}
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{
@ -120,7 +115,7 @@ public class UsersController : Controller
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
{
await _deleteOrganizationUserCommand.DeleteUserAsync(organizationId, id, EventSystemUser.SCIM);
await _removeOrganizationUserCommand.RemoveUserAsync(organizationId, id, EventSystemUser.SCIM);
return new NoContentResult();
}
}

View File

@ -60,6 +60,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
# Copy app from the build stage

View File

@ -0,0 +1,9 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommandvNext
{
Task PatchGroupAsync(Group group, ScimPatchModel model);
}

View File

@ -0,0 +1,170 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
namespace Bit.Scim.Groups;
public class PatchGroupCommandvNext : IPatchGroupCommandvNext
{
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommandvNext> _logger;
private readonly IOrganizationRepository _organizationRepository;
public PatchGroupCommandvNext(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommandvNext> logger,
IOrganizationRepository organizationRepository)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
_organizationRepository = organizationRepository;
}
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
{
foreach (var operation in model.Operations)
{
await HandleOperationAsync(group, operation);
}
}
private async Task HandleOperationAsync(Group group, ScimPatchModel.OperationModel operation)
{
switch (operation.Op?.ToLowerInvariant())
{
// Replace a list of members
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
break;
}
// Replace group name from path
case PatchOps.Replace when operation.Path?.ToLowerInvariant() == PatchPaths.DisplayName:
{
group.Name = operation.Value.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Replace group name from value object
case PatchOps.Replace when
string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty):
{
group.Name = displayNameProperty.GetString();
var organization = await _organizationRepository.GetByIdAsync(group.OrganizationId);
if (organization == null)
{
throw new NotFoundException();
}
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
break;
}
// Add a single member
case PatchOps.Add when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var addId):
{
await AddMembersAsync(group, [addId]);
break;
}
// Add a list of members
case PatchOps.Add when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
await AddMembersAsync(group, GetOperationValueIds(operation.Value));
break;
}
// Remove a single member
case PatchOps.Remove when
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.StartsWith("members[value eq ", StringComparison.OrdinalIgnoreCase) &&
TryGetOperationPathId(operation.Path, out var removeId):
{
await _groupService.DeleteUserAsync(group, removeId, EventSystemUser.SCIM);
break;
}
// Remove a list of members
case PatchOps.Remove when
operation.Path?.ToLowerInvariant() == PatchPaths.Members:
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
break;
}
default:
{
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
break;
}
}
}
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
{
// Azure Entra ID is known to send redundant "add" requests for each existing member every time any member
// is removed. To avoid excessive load on the database, we check against the high availability replica and
// return early if they already exist.
var groupMembers = await _groupRepository.GetManyUserIdsByIdAsync(group.Id, useReadOnlyReplica: true);
if (usersToAdd.IsSubsetOf(groupMembers))
{
_logger.LogDebug("Ignoring duplicate SCIM request to add members {Members} to group {Group}", usersToAdd, group.Id);
return;
}
await _groupRepository.AddGroupUsersByIdAsync(group.Id, usersToAdd);
}
private static HashSet<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new HashSet<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}
private static bool TryGetOperationPathId(string path, out Guid pathId)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
}
}

View File

@ -1,11 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
@ -14,17 +11,13 @@ namespace Bit.Scim.Groups;
public class PostGroupCommand : IPostGroupCommand
{
private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly ICreateGroupCommand _createGroupCommand;
public PostGroupCommand(
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IScimContext scimContext,
ICreateGroupCommand createGroupCommand)
{
_groupRepository = groupRepository;
_scimContext = scimContext;
_createGroupCommand = createGroupCommand;
}
@ -50,11 +43,6 @@ public class PostGroupCommand : IPostGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
{
return;
}
if (model.Members == null)
{
return;

View File

@ -1,10 +1,8 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
@ -13,16 +11,13 @@ namespace Bit.Scim.Groups;
public class PutGroupCommand : IPutGroupCommand
{
private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly IUpdateGroupCommand _updateGroupCommand;
public PutGroupCommand(
IGroupRepository groupRepository,
IScimContext scimContext,
IUpdateGroupCommand updateGroupCommand)
{
_groupRepository = groupRepository;
_scimContext = scimContext;
_updateGroupCommand = updateGroupCommand;
}
@ -43,11 +38,6 @@ public class PutGroupCommand : IPutGroupCommand
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{
if (_scimContext.RequestScimProvider != ScimProviderType.Okta)
{
return;
}
if (model.Members == null)
{
return;

View File

@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations;
public class GetUsersQueryParamModel
{
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,8 +1,66 @@
namespace Bit.Scim.Models;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Enums;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Scim.Models;
public class ScimUserRequestModel : BaseScimUserModel
{
public ScimUserRequestModel()
: base(false)
{ }
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
{
return new OrganizationUserInvite
{
Emails = new[] { EmailForInvite(scimProvider) },
// Permissions cannot be set via SCIM so we use default values
Type = OrganizationUserType.User,
Collections = new List<CollectionAccessSelection>(),
Groups = new List<Guid>()
};
}
private string EmailForInvite(ScimProviderType scimProvider)
{
var email = PrimaryEmail?.ToLowerInvariant();
if (!string.IsNullOrWhiteSpace(email))
{
return email;
}
switch (scimProvider)
{
case ScimProviderType.AzureAd:
return UserName?.ToLowerInvariant();
default:
email = WorkEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
email = Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
}
return email;
}
}
public string ExternalIdForInvite()
{
if (!string.IsNullOrWhiteSpace(ExternalId))
{
return ExternalId;
}
if (!string.IsNullOrWhiteSpace(UserName))
{
return UserName;
}
return CoreHelpers.RandomString(15);
}
}

View File

@ -1,4 +1,5 @@
using System.Globalization;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
@ -7,7 +8,7 @@ using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;
@ -68,6 +69,8 @@ public class Startup
// Services
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddDistributedCache(globalSettings);
services.AddBillingOperations();
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();

View File

@ -13,22 +13,28 @@ public class GetUsersListQuery : IGetUsersListQuery
_organizationUserRepository = organizationUserRepository;
}
public async Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex)
public async Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, GetUsersQueryParamModel userQueryParams)
{
string emailFilter = null;
string usernameFilter = null;
string externalIdFilter = null;
int count = userQueryParams.Count;
int startIndex = userQueryParams.StartIndex;
string filter = userQueryParams.Filter;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("userName eq "))
var filterLower = filter.ToLowerInvariant();
if (filterLower.StartsWith("username eq "))
{
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
usernameFilter = filterLower.Substring(12).Trim('"');
if (usernameFilter.Contains("@"))
{
emailFilter = usernameFilter;
}
}
else if (filter.StartsWith("externalId eq "))
else if (filterLower.StartsWith("externalid eq "))
{
externalIdFilter = filter.Substring(14).Trim('"');
}
@ -55,11 +61,11 @@ public class GetUsersListQuery : IGetUsersListQuery
}
totalResults = userList.Count;
}
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
else if (string.IsNullOrWhiteSpace(filter))
{
userList = orgUsers.OrderBy(ou => ou.Email)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.Skip(startIndex - 1)
.Take(count)
.ToList();
totalResults = orgUsers.Count;
}

View File

@ -4,5 +4,5 @@ namespace Bit.Scim.Users.Interfaces;
public interface IGetUsersListQuery
{
Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex);
Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, GetUsersQueryParamModel userQueryParams);
}

View File

@ -9,18 +9,15 @@ namespace Bit.Scim.Users;
public class PatchUserCommand : IPatchUserCommand
{
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly ILogger<PatchUserCommand> _logger;
public PatchUserCommand(
IUserService userService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
ILogger<PatchUserCommand> logger)
{
_userService = userService;
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_logger = logger;
@ -74,7 +71,7 @@ public class PatchUserCommand : IPatchUserCommand
{
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
{
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
return true;
}
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)

View File

@ -1,11 +1,8 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.Enums;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
@ -36,23 +33,11 @@ public class PostUserCommand : IPostUserCommand
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{
var email = model.PrimaryEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
switch (_scimContext.RequestScimProvider)
{
case ScimProviderType.AzureAd:
email = model.UserName?.ToLowerInvariant();
break;
default:
email = model.WorkEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
}
break;
}
}
var scimProvider = _scimContext.RequestScimProvider;
var invite = model.ToOrganizationUserInvite(scimProvider);
var email = invite.Emails.Single();
var externalId = model.ExternalIdForInvite();
if (string.IsNullOrWhiteSpace(email) || !model.Active)
{
@ -66,20 +51,6 @@ public class PostUserCommand : IPostUserCommand
throw new ConflictException();
}
string externalId = null;
if (!string.IsNullOrWhiteSpace(model.ExternalId))
{
externalId = model.ExternalId;
}
else if (!string.IsNullOrWhiteSpace(model.UserName))
{
externalId = model.UserName;
}
else
{
externalId = CoreHelpers.RandomString(15);
}
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
if (orgUserByExternalId != null)
{
@ -87,12 +58,11 @@ public class PostUserCommand : IPostUserCommand
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
invite.AccessSecretsManager = hasStandaloneSecretsManager;
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email,
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>(), hasStandaloneSecretsManager);
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
invite, externalId);
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser;

View File

@ -3,7 +3,7 @@ using System.Text.Encodings.Web;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using IdentityModel;
using Duende.IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;

View File

@ -7,3 +7,16 @@ public static class ScimConstants
public const string Scim2SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User";
public const string Scim2SchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group";
}
public static class PatchOps
{
public const string Replace = "replace";
public const string Add = "add";
public const string Remove = "remove";
}
public static class PatchPaths
{
public const string Members = "members";
public const string DisplayName = "displayname";
}

View File

@ -10,6 +10,7 @@ public static class ScimServiceCollectionExtensions
public static void AddScimGroupCommands(this IServiceCollection services)
{
services.AddScoped<IPatchGroupCommand, PatchGroupCommand>();
services.AddScoped<IPatchGroupCommandvNext, PatchGroupCommandvNext>();
services.AddScoped<IPostGroupCommand, PostGroupCommand>();
services.AddScoped<IPutGroupCommand, PutGroupCommand>();
}

View File

@ -40,4 +40,10 @@ if [[ $globalSettings__selfHosted == "true" ]]; then
&& update-ca-certificates
fi
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi
exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll

View File

@ -8,6 +8,7 @@ using Bit.Core.Auth.Models;
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.Models.Api;
@ -18,10 +19,10 @@ using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Services;
using Duende.IdentityServer.Stores;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -51,6 +52,7 @@ public class AccountController : Controller
private readonly Core.Services.IEventService _eventService;
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand;
public AccountController(
IAuthenticationSchemeProvider schemeProvider,
@ -70,7 +72,8 @@ public class AccountController : Controller
IGlobalSettings globalSettings,
Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository)
IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand)
{
_schemeProvider = schemeProvider;
_clientStore = clientStore;
@ -90,6 +93,7 @@ public class AccountController : Controller
_globalSettings = globalSettings;
_dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand;
}
[HttpGet]
@ -483,7 +487,8 @@ public class AccountController : Controller
if (orgUser.Status == OrganizationUserStatusType.Invited)
{
// Org User is invited - they must manually accept the invite via email and authenticate with MP
throw new Exception(_i18nService.T("UserAlreadyInvited", email, organization.DisplayName()));
// This allows us to enroll them in MP reset if required
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
}
// Accepted or Confirmed - create SSO link and return;
@ -497,7 +502,6 @@ public class AccountController : Controller
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats;
var prorationDate = DateTime.UtcNow;
if (availableSeats < 1)
{
try
@ -507,13 +511,13 @@ public class AccountController : Controller
throw new Exception("Cannot autoscale on self-hosted instance.");
}
await _organizationService.AutoAddSeatsAsync(organization, 1, prorationDate);
await _organizationService.AutoAddSeatsAsync(organization, 1);
}
catch (Exception e)
{
if (organization.Seats.Value != initialSeatCount)
{
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value, prorationDate);
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value);
}
_logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
@ -538,7 +542,7 @@ public class AccountController : Controller
EmailVerified = emailVerified,
ApiKey = CoreHelpers.SecureRandomString(30)
};
await _userService.RegisterUserAsync(user);
await _registerUserCommand.RegisterUser(user);
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy =

View File

@ -79,6 +79,7 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
krb5-user \
&& rm -rf /var/lib/apt/lists/*
# Copy app from the build stage

View File

@ -10,7 +10,7 @@
<!-- This is a transitive dependency to Sustainsys.Saml2.AspNetCore2 -->
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.2.2" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.9.2" />
<PackageReference Include="Sustainsys.Saml2.AspNetCore2" Version="2.10.0" />
</ItemGroup>
<ItemGroup>

View File

@ -1,4 +1,5 @@
using Bit.Core;
using Bit.Core.Billing.Extensions;
using Bit.Core.Context;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories.Noop;
@ -80,6 +81,7 @@ public class Startup
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddCoreLocalizationServices();
services.AddBillingOperations();
// TODO: Remove when OrganizationUser methods are moved out of OrganizationService, this noop dependency should
// TODO: no longer be required - see PM-1880

View File

@ -7,9 +7,9 @@ using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Sso.Models;
using Bit.Sso.Utilities;
using Duende.IdentityModel;
using Duende.IdentityServer;
using Duende.IdentityServer.Infrastructure;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.Extensions.Options;

View File

@ -7,15 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - SSO</title>
<link rel="stylesheet" href="~/css/webfonts.css" />
<environment include="Development">
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.css" />
<link rel="stylesheet" href="~/css/site.css" />
</environment>
<environment exclude="Development">
<link rel="stylesheet" href="~/lib/font-awesome/css/font-awesome.min.css" asp-append-version="true" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
</environment>
<link rel="stylesheet" href="~/assets/site.css" asp-append-version="true" />
@RenderSection("Head", required: false)
</head>
<body>
@ -31,7 +23,7 @@
@RenderBody()
</div>
<div class="container footer text-muted">
<div class="container footer text-body-secondary">
<div class="row">
<div class="col">
&copy; @DateTime.Now.Year, Bitwarden Inc.
@ -43,18 +35,7 @@
</div>
</div>
<environment include="Development">
<script src="~/lib/jquery/jquery.slim.js"></script>
<script src="~/lib/popper/popper.js"></script>
<script src="~/lib/bootstrap/js/bootstrap.js"></script>
</environment>
<environment exclude="Development">
<script src="~/lib/jquery/jquery.slim.min.js" asp-append-version="true"></script>
<script src="~/lib/popper/popper.min.js" asp-append-version="true"></script>
<script src="~/lib/bootstrap/js/bootstrap.min.js" asp-append-version="true"></script>
</environment>
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/assets/site.js" asp-append-version="true"></script>
@RenderSection("Scripts", required: false)
</body>
</html>

View File

@ -46,4 +46,10 @@ if [[ $globalSettings__selfHosted == "true" ]]; then
&& update-ca-certificates
fi
if [[ -f "/etc/bitwarden/kerberos/bitwarden.keytab" && -f "/etc/bitwarden/kerberos/krb5.conf" ]]; then
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
gosu $USERNAME:$GROUPNAME kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
fi
exec gosu $USERNAME:$GROUPNAME dotnet /app/Sso.dll

View File

@ -1,71 +0,0 @@
/// <binding BeforeBuild='build' Clean='clean' ProjectOpened='build' />
const gulp = require('gulp');
const merge = require('merge-stream');
const sass = require('gulp-sass')(require("sass"));
const del = require('del');
const paths = {};
paths.webroot = './wwwroot/';
paths.npmDir = './node_modules/';
paths.sassDir = './Sass/';
paths.libDir = paths.webroot + 'lib/';
paths.cssDir = paths.webroot + 'css/';
paths.jsDir = paths.webroot + 'js/';
paths.sass = paths.sassDir + '**/*.scss';
paths.minCss = paths.cssDir + '**/*.min.css';
paths.js = paths.jsDir + '**/*.js';
paths.minJs = paths.jsDir + '**/*.min.js';
paths.libJs = paths.libDir + '**/*.js';
paths.libMinJs = paths.libDir + '**/*.min.js';
function clean() {
return del([paths.minJs, paths.cssDir, paths.libDir]);
}
function lib() {
const libs = [
{
src: paths.npmDir + 'bootstrap/dist/js/*',
dest: paths.libDir + 'bootstrap/js'
},
{
src: paths.npmDir + 'popper.js/dist/umd/*',
dest: paths.libDir + 'popper'
},
{
src: paths.npmDir + 'font-awesome/css/*',
dest: paths.libDir + 'font-awesome/css'
},
{
src: paths.npmDir + 'font-awesome/fonts/*',
dest: paths.libDir + 'font-awesome/fonts'
},
{
src: paths.npmDir + 'jquery/dist/jquery.slim*',
dest: paths.libDir + 'jquery'
},
];
const tasks = libs.map((lib) => {
return gulp.src(lib.src).pipe(gulp.dest(lib.dest));
});
return merge(tasks);
}
function runSass() {
return gulp.src(paths.sass)
.pipe(sass({ outputStyle: 'compressed' }).on('error', sass.logError))
.pipe(gulp.dest(paths.cssDir));
}
function sassWatch() {
gulp.watch(paths.sass, runSass);
}
exports.build = gulp.series(clean, gulp.parallel([lib, runSass]));
exports['sass:watch'] = sassWatch;
exports.sass = runSass;
exports.lib = lib;
exports.clean = clean;

File diff suppressed because it is too large Load Diff

View File

@ -5,17 +5,20 @@
"repository": "https://github.com/bitwarden/enterprise",
"license": "-",
"scripts": {
"build": "gulp build"
"build": "webpack"
},
"dependencies": {
"bootstrap": "5.3.3",
"font-awesome": "4.7.0",
"jquery": "3.7.1"
},
"devDependencies": {
"bootstrap": "4.6.2",
"del": "6.1.1",
"font-awesome": "4.7.0",
"gulp": "4.0.2",
"gulp-sass": "5.1.0",
"jquery": "3.7.1",
"merge-stream": "2.0.0",
"popper.js": "1.16.1",
"sass": "1.75.0"
"css-loader": "7.1.2",
"expose-loader": "5.0.0",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.85.0",
"sass-loader": "16.0.4",
"webpack": "5.97.1",
"webpack-cli": "5.1.4"
}
}

View File

@ -0,0 +1,55 @@
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const paths = {
assets: "./wwwroot/assets/",
sassDir: "./Sass/",
};
/** @type {import("webpack").Configuration} */
module.exports = {
mode: "production",
devtool: "source-map",
entry: {
site: [
path.resolve(__dirname, paths.sassDir, "site.scss"),
"bootstrap",
"jquery",
"font-awesome/css/font-awesome.css",
],
},
output: {
clean: true,
path: path.resolve(__dirname, paths.assets),
},
module: {
rules: [
{
test: /\.(sa|sc|c)ss$/,
use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
},
{
test: /.(ttf|otf|eot|svg|woff(2)?)(\?[a-z0-9]+)?$/,
exclude: /loading(|-white).svg/,
generator: {
filename: "fonts/[name].[contenthash][ext]",
},
type: "asset/resource",
},
// Expose jquery globally so they can be used directly in asp.net
{
test: require.resolve("jquery"),
loader: "expose-loader",
options: {
exposes: ["$", "jQuery"],
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].css",
}),
],
};

View File

@ -1,4 +0,0 @@
// Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
// for details on configuring this project to bundle and minify static web assets.
// Write your JavaScript code.

View File

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.Build.Traversal">
<ItemGroup>
<ProjectReference Include="**\*.*proj" />
</ItemGroup>
</Project>

View File

@ -3,6 +3,7 @@ using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Enums;
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
@ -19,23 +20,30 @@ public class CreateProviderCommandTests
[Theory, BitAutoData]
public async Task CreateMspAsync_UserIdIsInvalid_Throws(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMspAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task CreateMspAsync_Success(Provider provider, User user, SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMspAsync(provider, user.Email, default, default);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
@ -43,11 +51,52 @@ public class CreateProviderCommandTests
[Theory, BitAutoData]
public async Task CreateResellerAsync_Success(Provider provider, SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Reseller;
// Act
await sutProvider.Sut.CreateResellerAsync(provider);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IProviderService>().DidNotReceiveWithAnyArgs().SendProviderSetupInviteEmailAsync(default, default);
}
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_Success(
Provider provider,
User user,
PlanType plan,
int minimumSeats,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.MultiOrganizationEnterprise;
var userRepository = sutProvider.GetDependency<IUserRepository>();
userRepository.GetByEmailAsync(user.Email).Returns(user);
// Act
await sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, user.Email, plan, minimumSeats);
// Assert
await sutProvider.GetDependency<IProviderRepository>().ReceivedWithAnyArgs().CreateAsync(provider);
await sutProvider.GetDependency<IProviderService>().Received(1).SendProviderSetupInviteEmailAsync(provider, user.Email);
}
[Theory, BitAutoData]
public async Task CreateMultiOrganizationEnterpriseAsync_UserIdIsInvalid_Throws(
Provider provider,
SutProvider<CreateProviderCommand> sutProvider)
{
// Arrange
provider.Type = ProviderType.Msp;
// Act
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.CreateMultiOrganizationEnterpriseAsync(provider, default, default, default));
// Assert
Assert.Contains("Invalid owner.", exception.Message);
}
}

View File

@ -1,20 +1,23 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Stripe;
using Xunit;
using IMailService = Bit.Core.Services.IMailService;
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
@ -73,10 +76,10 @@ public class RemoveOrganizationFromProviderCommandTests
{
providerOrganization.ProviderId = provider.Id;
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
includeProvider: false)
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
[],
includeProvider: false)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization));
@ -85,56 +88,53 @@ public class RemoveOrganizationFromProviderCommandTests
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoStripeObjects_MakesCorrectInvocations(
public async Task RemoveOrganizationFromProvider_OrganizationNotStripeEnabled_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
organization.GatewayCustomerId = null;
organization.GatewaySubscriptionId = null;
providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@gmail.com", "b@gmail.com" };
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.DidNotReceiveWithAnyArgs().CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
await stripeAdapter.DidNotReceiveWithAnyArgs().SubscriptionUpdateAsync(Arg.Any<string>(), Arg.Any<SubscriptionUpdateOptions>());
await sutProvider.GetDependency<IMailService>().DidNotReceiveWithAnyArgs().SendProviderUpdatePaymentMethod(
Arg.Any<Guid>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<IEnumerable<string>>());
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
await sutProvider.GetDependency<IStripeAdapter>().DidNotReceiveWithAnyArgs()
.CustomerUpdateAsync(Arg.Any<string>(), Arg.Any<CustomerUpdateOptions>());
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations__FeatureFlagOff(
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_NonConsolidatedBilling_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
@ -142,104 +142,146 @@ public class RemoveOrganizationFromProviderCommandTests
{
providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
});
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
sutProvider.GetDependency<IStripeAdapter>().SubscriptionGetAsync(organization.GatewaySubscriptionId)
.Returns(GetSubscription(organization.GatewaySubscriptionId));
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@example.com"));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).CustomerUpdateAsync(organization.GatewayCustomerId,
Arg.Is<CustomerUpdateOptions>(options =>
options.Coupon == string.Empty && options.Email == "a@example.com"));
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@example.com") && emails.Contains("b@example.com")));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(organization.GatewaySubscriptionId,
Arg.Is<SubscriptionUpdateOptions>(options =>
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30));
await sutProvider.GetDependency<ISubscriberService>().Received(1).RemovePaymentSource(organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(org => org.BillingEmail == "a@example.com"));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_CreatesSubscriptionAndScalesSeats_FeatureFlagON(Provider provider,
public async Task RemoveOrganizationFromProvider_OrganizationStripeEnabled_ConsolidatedBilling_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
provider.Status = ProviderStatusType.Billable;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.ProviderId = provider.Id;
organization.Status = OrganizationStatusType.Managed;
organization.PlanType = PlanType.TeamsMonthly;
var teamsMonthlyPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(PlanType.TeamsMonthly).Returns(teamsMonthlyPlan);
sutProvider.GetDependency<IHasConfirmedOwnersExceptQuery>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
[],
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@example.com", "b@example.com" };
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns([
"a@example.com",
"b@example.com"
]);
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
stripeAdapter.SubscriptionCreateAsync(default).ReturnsForAnyArgs(new Stripe.Subscription
stripeAdapter.SubscriptionCreateAsync(Arg.Any<SubscriptionCreateOptions>()).Returns(new Subscription
{
Id = "S-1",
CurrentPeriodEnd = DateTime.Today.AddDays(10),
Id = "subscription_id"
});
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await stripeAdapter.Received(1).CustomerUpdateAsync(
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Coupon == string.Empty && options.Email == "a@example.com"));
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(c =>
c.Customer == organization.GatewayCustomerId &&
c.CollectionMethod == "send_invoice" &&
c.DaysUntilDue == 30 &&
c.Items.Count == 1
));
await stripeAdapter.Received(1).SubscriptionCreateAsync(Arg.Is<SubscriptionCreateOptions>(options =>
options.Customer == organization.GatewayCustomerId &&
options.CollectionMethod == StripeConstants.CollectionMethod.SendInvoice &&
options.DaysUntilDue == 30 &&
options.AutomaticTax.Enabled == true &&
options.Metadata["organizationId"] == organization.Id.ToString() &&
options.OffSession == true &&
options.ProrationBehavior == StripeConstants.ProrationBehavior.CreateProrations &&
options.Items.First().Price == teamsMonthlyPlan.PasswordManager.StripeSeatPlanId &&
options.Items.First().Quantity == organization.Seats));
await sutProvider.GetDependency<IScaleSeatsCommand>().Received(1)
.ScalePasswordManagerSeats(provider, organization.PlanType, -(int)organization.Seats);
await sutProvider.GetDependency<IProviderBillingService>().Received(1)
.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "S-1"));
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails =>
emails.Contains("a@example.com") && emails.Contains("b@example.com")));
org =>
org.BillingEmail == "a@example.com" &&
org.GatewaySubscriptionId == "subscription_id" &&
org.Status == OrganizationStatusType.Created));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IEventService>().Received(1)
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
await sutProvider.GetDependency<IMailService>().Received(1)
.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.FirstOrDefault() == "a@example.com"));
}
private static Subscription GetSubscription(string subscriptionId) =>
new()
{
Id = subscriptionId,
Status = StripeConstants.SubscriptionStatus.Active,
Items = new StripeList<SubscriptionItem>
{
Data = new List<SubscriptionItem>
{
new()
{
Id = "sub_item_123",
Price = new Price()
{
Id = "2023-enterprise-org-seat-annually"
}
}
}
}
};
}

View File

@ -1,12 +1,14 @@
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.Repositories;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -53,9 +55,9 @@ public class ProviderServiceTests
}
[Theory, BitAutoData]
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key,
[ProviderUser(ProviderUserStatusType.Confirmed, ProviderUserType.ProviderAdmin)] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
public async Task CompleteSetupAsync_Success(User user, Provider provider, string key, TaxInfo taxInfo,
[ProviderUser] ProviderUser providerUser,
SutProvider<ProviderService> sutProvider)
{
providerUser.ProviderId = provider.Id;
providerUser.UserId = user.Id;
@ -69,13 +71,27 @@ public class ProviderServiceTests
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, taxInfo).Returns(customer);
var subscription = new Subscription { Id = "subscription_id" };
providerBillingService.SetupSubscription(provider).Returns(subscription);
sutProvider.Create();
var token = protector.Protect($"ProviderSetupInvite {provider.Id} {user.Email} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key);
await sutProvider.Sut.CompleteSetupAsync(provider, user.Id, token, key, taxInfo);
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(Arg.Is<Provider>(
p =>
p.GatewayCustomerId == customer.Id &&
p.GatewaySubscriptionId == subscription.Id &&
p.Status == ProviderStatusType.Billable));
await sutProvider.GetDependency<IProviderRepository>().Received().UpsertAsync(provider);
await sutProvider.GetDependency<IProviderUserRepository>().Received()
.ReplaceAsync(Arg.Is<ProviderUser>(pu => pu.UserId == user.Id && pu.ProviderId == provider.Id && pu.Key == key));
}
@ -442,7 +458,7 @@ public class ProviderServiceTests
public async Task AddOrganization_OrganizationHasSecretsManager_Throws(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseMonthly;
organization.UseSecretsManager = true;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
@ -459,7 +475,7 @@ public class ProviderServiceTests
public async Task AddOrganization_Success(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
organization.PlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseMonthly;
var providerRepository = sutProvider.GetDependency<IProviderRepository>();
providerRepository.GetByIdAsync(provider.Id).Returns(provider);
@ -502,8 +518,8 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
var expectedPlanType = PlanType.EnterpriseAnnually;
organization.PlanType = PlanType.EnterpriseAnnually;
var expectedPlanType = PlanType.EnterpriseMonthly;
organization.PlanType = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IOrganizationRepository>().GetByIdAsync(organization.Id).Returns(organization);
await sutProvider.Sut.AddOrganization(provider.Id, organization.Id, key);
@ -532,12 +548,18 @@ public class ProviderServiceTests
BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp;
organization.PlanType = PlanType.EnterpriseAnnually;
organization.Plan = "Enterprise (Annually)";
organization.PlanType = PlanType.EnterpriseMonthly;
organization.Plan = "Enterprise (Monthly)";
var expectedPlanType = PlanType.EnterpriseAnnually2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(organization.PlanType)
.Returns(StaticStore.GetPlan(organization.PlanType));
var expectedPlanId = "2020-enterprise-org-seat-annually";
var expectedPlanType = PlanType.EnterpriseMonthly2020;
sutProvider.GetDependency<IPricingClient>().GetPlanOrThrow(expectedPlanType)
.Returns(StaticStore.GetPlan(expectedPlanType));
var expectedPlanId = "2020-enterprise-org-seat-monthly";
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
@ -612,15 +634,15 @@ public class ProviderServiceTests
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default);
}
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
[Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
{
organizationSignup.Plan = PlanType.EnterpriseAnnually;
organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, new Collection()));
var providerOrganization =
@ -631,18 +653,17 @@ public class ProviderServiceTests
.Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>()
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
.Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t => t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll &&
!t.First().Item1.Collections.Any() &&
t.First().Item1.Collections.Count() == 1 &&
t.First().Item2 == null));
}
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvalidPlanType_ThrowsBadRequestException(
[Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_InvalidPlanType_ThrowsBadRequestException(
Provider provider,
OrganizationSignup organizationSignup,
Organization organization,
@ -650,8 +671,6 @@ public class ProviderServiceTests
User user,
SutProvider<ProviderService> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable;
@ -670,8 +689,8 @@ public class ProviderServiceTests
await providerOrganizationRepository.DidNotReceiveWithAnyArgs().CreateAsync(default);
}
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
public async Task CreateOrganizationAsync_ConsolidatedBillingEnabled_InvokeSignupClientAsync(
[Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_InvokeSignupClientAsync(
Provider provider,
OrganizationSignup organizationSignup,
Organization organization,
@ -679,8 +698,6 @@ public class ProviderServiceTests
User user,
SutProvider<ProviderService> sutProvider)
{
sutProvider.GetDependency<IFeatureService>().IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling).Returns(true);
provider.Type = ProviderType.Msp;
provider.Status = ProviderStatusType.Billable;
@ -709,27 +726,27 @@ public class ProviderServiceTests
.InviteUsersAsync(
organization.Id,
user.Id,
systemUser: null,
Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t =>
t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll &&
!t.First().Item1.Collections.Any() &&
t.First().Item1.Collections.Count() == 1 &&
t.First().Item2 == null));
}
[Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData]
public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse
[Theory, OrganizationCustomize, BitAutoData]
public async Task CreateOrganizationAsync_SetsAccessAllToFalse
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
{
organizationSignup.Plan = PlanType.EnterpriseAnnually;
organizationSignup.Plan = PlanType.EnterpriseMonthly;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
sutProvider.GetDependency<IOrganizationService>().SignupClientAsync(organizationSignup)
.Returns((organization, null as OrganizationUser, defaultCollection));
var providerOrganization =
@ -740,12 +757,11 @@ public class ProviderServiceTests
.Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>()
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
.Received().InviteUsersAsync(organization.Id, user.Id, systemUser: null, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t => t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll == false &&
t.First().Item1.Collections.Single().Id == defaultCollection.Id &&
!t.First().Item1.Collections.Single().HidePasswords &&
!t.First().Item1.Collections.Single().ReadOnly &&

View File

@ -0,0 +1,151 @@
using Bit.Core.Billing.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Xunit;
namespace Bit.Commercial.Core.Test.Billing;
[SutProviderCustomize]
public class TaxServiceTests
{
[Theory]
[BitAutoData("AD", "A-123456-Z", "ad_nrt")]
[BitAutoData("AD", "A123456Z", "ad_nrt")]
[BitAutoData("AR", "20-12345678-9", "ar_cuit")]
[BitAutoData("AR", "20123456789", "ar_cuit")]
[BitAutoData("AU", "01259983598", "au_abn")]
[BitAutoData("AU", "123456789123", "au_arn")]
[BitAutoData("AT", "ATU12345678", "eu_vat")]
[BitAutoData("BH", "123456789012345", "bh_vat")]
[BitAutoData("BY", "123456789", "by_tin")]
[BitAutoData("BE", "BE0123456789", "eu_vat")]
[BitAutoData("BO", "123456789", "bo_tin")]
[BitAutoData("BR", "01.234.456/5432-10", "br_cnpj")]
[BitAutoData("BR", "01234456543210", "br_cnpj")]
[BitAutoData("BR", "123.456.789-87", "br_cpf")]
[BitAutoData("BR", "12345678987", "br_cpf")]
[BitAutoData("BG", "123456789", "bg_uic")]
[BitAutoData("BG", "BG012100705", "eu_vat")]
[BitAutoData("CA", "100728494", "ca_bn")]
[BitAutoData("CA", "123456789RT0001", "ca_gst_hst")]
[BitAutoData("CA", "PST-1234-1234", "ca_pst_bc")]
[BitAutoData("CA", "123456-7", "ca_pst_mb")]
[BitAutoData("CA", "1234567", "ca_pst_sk")]
[BitAutoData("CA", "1234567890TQ1234", "ca_qst")]
[BitAutoData("CL", "11.121.326-1", "cl_tin")]
[BitAutoData("CL", "11121326-1", "cl_tin")]
[BitAutoData("CL", "23.121.326-K", "cl_tin")]
[BitAutoData("CL", "43651326-K", "cl_tin")]
[BitAutoData("CN", "123456789012345678", "cn_tin")]
[BitAutoData("CN", "123456789012345", "cn_tin")]
[BitAutoData("CO", "123.456.789-0", "co_nit")]
[BitAutoData("CO", "1234567890", "co_nit")]
[BitAutoData("CR", "1-234-567890", "cr_tin")]
[BitAutoData("CR", "1234567890", "cr_tin")]
[BitAutoData("HR", "HR12345678912", "eu_vat")]
[BitAutoData("HR", "12345678901", "hr_oib")]
[BitAutoData("CY", "CY12345678X", "eu_vat")]
[BitAutoData("CZ", "CZ12345678", "eu_vat")]
[BitAutoData("DK", "DK12345678", "eu_vat")]
[BitAutoData("DO", "123-4567890-1", "do_rcn")]
[BitAutoData("DO", "12345678901", "do_rcn")]
[BitAutoData("EC", "1234567890001", "ec_ruc")]
[BitAutoData("EG", "123456789", "eg_tin")]
[BitAutoData("SV", "1234-567890-123-4", "sv_nit")]
[BitAutoData("SV", "12345678901234", "sv_nit")]
[BitAutoData("EE", "EE123456789", "eu_vat")]
[BitAutoData("EU", "EU123456789", "eu_oss_vat")]
[BitAutoData("FI", "FI12345678", "eu_vat")]
[BitAutoData("FR", "FR12345678901", "eu_vat")]
[BitAutoData("GE", "123456789", "ge_vat")]
[BitAutoData("DE", "1234567890", "de_stn")]
[BitAutoData("DE", "DE123456789", "eu_vat")]
[BitAutoData("GR", "EL123456789", "eu_vat")]
[BitAutoData("HK", "12345678", "hk_br")]
[BitAutoData("HU", "HU12345678", "eu_vat")]
[BitAutoData("HU", "12345678-1-23", "hu_tin")]
[BitAutoData("HU", "12345678123", "hu_tin")]
[BitAutoData("IS", "123456", "is_vat")]
[BitAutoData("IN", "12ABCDE1234F1Z5", "in_gst")]
[BitAutoData("IN", "12ABCDE3456FGZH", "in_gst")]
[BitAutoData("ID", "012.345.678.9-012.345", "id_npwp")]
[BitAutoData("ID", "0123456789012345", "id_npwp")]
[BitAutoData("IE", "IE1234567A", "eu_vat")]
[BitAutoData("IE", "IE1234567AB", "eu_vat")]
[BitAutoData("IL", "000012345", "il_vat")]
[BitAutoData("IL", "123456789", "il_vat")]
[BitAutoData("IT", "IT12345678901", "eu_vat")]
[BitAutoData("JP", "1234567890123", "jp_cn")]
[BitAutoData("JP", "12345", "jp_rn")]
[BitAutoData("KZ", "123456789012", "kz_bin")]
[BitAutoData("KE", "P000111111A", "ke_pin")]
[BitAutoData("LV", "LV12345678912", "eu_vat")]
[BitAutoData("LI", "CHE123456789", "li_uid")]
[BitAutoData("LI", "12345", "li_vat")]
[BitAutoData("LT", "LT123456789123", "eu_vat")]
[BitAutoData("LU", "LU12345678", "eu_vat")]
[BitAutoData("MY", "12345678", "my_frp")]
[BitAutoData("MY", "C 1234567890", "my_itn")]
[BitAutoData("MY", "C1234567890", "my_itn")]
[BitAutoData("MY", "A12-3456-78912345", "my_sst")]
[BitAutoData("MY", "A12345678912345", "my_sst")]
[BitAutoData("MT", "MT12345678", "eu_vat")]
[BitAutoData("MX", "ABC010203AB9", "mx_rfc")]
[BitAutoData("MD", "1003600", "md_vat")]
[BitAutoData("MA", "12345678", "ma_vat")]
[BitAutoData("NL", "NL123456789B12", "eu_vat")]
[BitAutoData("NZ", "123456789", "nz_gst")]
[BitAutoData("NG", "12345678-0001", "ng_tin")]
[BitAutoData("NO", "123456789MVA", "no_vat")]
[BitAutoData("NO", "1234567", "no_voec")]
[BitAutoData("OM", "OM1234567890", "om_vat")]
[BitAutoData("PE", "12345678901", "pe_ruc")]
[BitAutoData("PH", "123456789012", "ph_tin")]
[BitAutoData("PL", "PL1234567890", "eu_vat")]
[BitAutoData("PT", "PT123456789", "eu_vat")]
[BitAutoData("RO", "RO1234567891", "eu_vat")]
[BitAutoData("RO", "1234567890123", "ro_tin")]
[BitAutoData("RU", "1234567891", "ru_inn")]
[BitAutoData("RU", "123456789", "ru_kpp")]
[BitAutoData("SA", "123456789012345", "sa_vat")]
[BitAutoData("RS", "123456789", "rs_pib")]
[BitAutoData("SG", "M12345678X", "sg_gst")]
[BitAutoData("SG", "123456789F", "sg_uen")]
[BitAutoData("SK", "SK1234567891", "eu_vat")]
[BitAutoData("SI", "SI12345678", "eu_vat")]
[BitAutoData("SI", "12345678", "si_tin")]
[BitAutoData("ZA", "4123456789", "za_vat")]
[BitAutoData("KR", "123-45-67890", "kr_brn")]
[BitAutoData("KR", "1234567890", "kr_brn")]
[BitAutoData("ES", "A12345678", "es_cif")]
[BitAutoData("ES", "ESX1234567X", "eu_vat")]
[BitAutoData("SE", "SE123456789012", "eu_vat")]
[BitAutoData("CH", "CHE-123.456.789 HR", "ch_uid")]
[BitAutoData("CH", "CHE123456789HR", "ch_uid")]
[BitAutoData("CH", "CHE-123.456.789 MWST", "ch_vat")]
[BitAutoData("CH", "CHE123456789MWST", "ch_vat")]
[BitAutoData("TW", "12345678", "tw_vat")]
[BitAutoData("TH", "1234567890123", "th_vat")]
[BitAutoData("TR", "0123456789", "tr_tin")]
[BitAutoData("UA", "123456789", "ua_vat")]
[BitAutoData("AE", "123456789012345", "ae_trn")]
[BitAutoData("GB", "XI123456789", "eu_vat")]
[BitAutoData("GB", "GB123456789", "gb_vat")]
[BitAutoData("US", "12-3456789", "us_ein")]
[BitAutoData("UY", "123456789012", "uy_ruc")]
[BitAutoData("UZ", "123456789", "uz_tin")]
[BitAutoData("UZ", "123456789012", "uz_vat")]
[BitAutoData("VE", "A-12345678-9", "ve_rif")]
[BitAutoData("VE", "A123456789", "ve_rif")]
[BitAutoData("VN", "1234567890", "vn_tin")]
public void GetStripeTaxCode_WithValidCountryAndTaxId_ReturnsExpectedTaxIdType(
string country,
string taxId,
string expected,
SutProvider<TaxService> sutProvider)
{
var result = sutProvider.Sut.GetStripeTaxCode(country, taxId);
Assert.Equal(expected, result);
}
}

View File

@ -0,0 +1,656 @@
using System.Reflection;
using System.Security.Claims;
using Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.AccessPolicies;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Enums.AccessPolicies;
using Bit.Core.SecretsManager.Models.Data.AccessPolicyUpdates;
using Bit.Core.SecretsManager.Queries.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Queries.Interfaces;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using NSubstitute;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretsManager.AuthorizationHandlers.AccessPolicies;
[SutProviderCustomize]
[ProjectCustomize]
public class SecretAccessPoliciesUpdatesAuthorizationHandlerTests
{
[Fact]
public void SecretAccessPoliciesOperations_OnlyPublicStatic()
{
var publicStaticFields =
typeof(SecretAccessPoliciesOperations).GetFields(BindingFlags.Public | BindingFlags.Static);
var allFields = typeof(SecretAccessPoliciesOperations).GetFields();
Assert.Equal(publicStaticFields.Length, allFields.Length);
}
[Theory]
[BitAutoData]
public async Task Handler_AccessSecretsManagerFalse_DoesNotSucceed(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
.Returns(false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.ServiceAccount)]
[BitAutoData(AccessClientType.Organization)]
public async Task Handler_UnsupportedClientTypes_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupUserSubstitutes(sutProvider, accessClientType, resource);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task Handler_UnsupportedServiceAccountGrantedPoliciesOperationRequirement_Throws(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
ClaimsPrincipal claimsPrincipal)
{
var requirement = new SecretAccessPoliciesOperationRequirement();
SetupUserSubstitutes(sutProvider, AccessClientType.NoAccessCheck, resource);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await Assert.ThrowsAsync<ArgumentException>(() => sutProvider.Sut.HandleAsync(authzContext));
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck, false, false)]
[BitAutoData(AccessClientType.NoAccessCheck, true, false)]
[BitAutoData(AccessClientType.User, false, false)]
[BitAutoData(AccessClientType.User, true, false)]
public async Task Handler_CanUpdateAsync_UserHasNoWriteAccessToSecret_DoesNotSucceed(
AccessClientType accessClientType,
bool readAccess,
bool writeAccess,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupUserSubstitutes(sutProvider, accessClientType, resource, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(resource.SecretId, userId, accessClientType)
.Returns((readAccess, writeAccess));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanUpdateAsync_TargetGranteesNotInSameOrganization_DoesNotSucceed(
bool orgUsersInSameOrg,
bool groupsInSameOrg,
bool serviceAccountsInSameOrg,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, orgUsersInSameOrg,
groupsInSameOrg, serviceAccountsInSameOrg);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanUpdateAsync_TargetGranteesNotInSameOrganizationHasZeroRequests_DoesNotSucceed(
bool orgUsersCountZero,
bool groupsCountZero,
bool serviceAccountsCountZero,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = ClearAccessPolicyUpdate(resource, orgUsersCountZero, groupsCountZero, serviceAccountsCountZero);
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, false, false,
false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_NoServiceAccountCreatesRequested_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = RemoveAllServiceAccountCreates(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_NoAccessToTargetServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupNoServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_ServiceAccountAccessResultsPartial_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupPartialServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_UserHasAccessToSomeServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupSomeServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanUpdateAsync_UserHasAccessToAllServiceAccounts_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Updates;
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupAllServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_NotCreationOperations_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
SetupUserSubstitutes(sutProvider, accessClientType, resource, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanCreateAsync_TargetGranteesNotInSameOrganization_DoesNotSucceed(
bool orgUsersInSameOrg,
bool groupsInSameOrg,
bool serviceAccountsInSameOrg,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, orgUsersInSameOrg,
groupsInSameOrg, serviceAccountsInSameOrg);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(false, false, false)]
[BitAutoData(true, false, false)]
[BitAutoData(false, true, false)]
[BitAutoData(true, true, false)]
[BitAutoData(false, false, true)]
[BitAutoData(true, false, true)]
[BitAutoData(false, true, true)]
public async Task Handler_CanCreateAsync_TargetGranteesNotInSameOrganizationHasZeroRequests_DoesNotSucceed(
bool orgUsersCountZero,
bool groupsCountZero,
bool serviceAccountsCountZero,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = ClearAccessPolicyUpdate(resource, orgUsersCountZero, groupsCountZero, serviceAccountsCountZero);
SetupSameOrganizationRequest(sutProvider, AccessClientType.NoAccessCheck, resource, userId, false, false,
false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_NoServiceAccountCreatesRequested_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = RemoveAllServiceAccountCreates(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_NoAccessToTargetServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupNoServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_ServiceAccountAccessResultsPartial_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupPartialServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_UserHasAccessToSomeServiceAccounts_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupSomeServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.User)]
public async Task Handler_CanCreateAsync_UserHasAccessToAllServiceAccounts_Success(
AccessClientType accessClientType,
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretAccessPoliciesOperations.Create;
resource = SetAllToCreates(resource);
resource = AddServiceAccountCreateUpdate(resource);
SetupSameOrganizationRequest(sutProvider, accessClientType, resource, userId);
SetupAllServiceAccountAccess(sutProvider, resource, userId, accessClientType);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resource);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
private static void SetupNoServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var createServiceAccountIds = resource.ServiceAccountAccessPolicyUpdates
.Where(ap => ap.Operation == AccessPolicyOperation.Create)
.Select(uap => uap.AccessPolicy.ServiceAccountId!.Value)
.ToList();
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(createServiceAccountIds.ToDictionary(id => id, _ => (false, false)));
}
private static void SetupPartialServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var accessResult = resource.ServiceAccountAccessPolicyUpdates
.Where(x => x.Operation == AccessPolicyOperation.Create)
.Select(x => x.AccessPolicy.ServiceAccountId!.Value)
.ToDictionary(id => id, _ => (true, true));
accessResult[accessResult.First().Key] = (true, true);
accessResult.Remove(accessResult.Last().Key);
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(accessResult);
}
private static void SetupSomeServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var accessResult = resource.ServiceAccountAccessPolicyUpdates
.Where(x => x.Operation == AccessPolicyOperation.Create)
.Select(x => x.AccessPolicy.ServiceAccountId!.Value)
.ToDictionary(id => id, _ => (false, false));
accessResult[accessResult.First().Key] = (true, true);
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(accessResult);
}
private static void SetupAllServiceAccountAccess(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
SecretAccessPoliciesUpdates resource,
Guid userId,
AccessClientType accessClientType)
{
var accessResult = resource.ServiceAccountAccessPolicyUpdates
.Where(x => x.Operation == AccessPolicyOperation.Create)
.Select(x => x.AccessPolicy.ServiceAccountId!.Value)
.ToDictionary(id => id, _ => (true, true));
sutProvider.GetDependency<IServiceAccountRepository>()
.AccessToServiceAccountsAsync(Arg.Any<List<Guid>>(), userId, accessClientType)
.Returns(accessResult);
}
private static void SetupUserSubstitutes(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
AccessClientType accessClientType,
SecretAccessPoliciesUpdates resource,
Guid userId = new())
{
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(resource.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, resource.OrganizationId)
.ReturnsForAnyArgs((accessClientType, userId));
}
private static void SetupSameOrganizationRequest(
SutProvider<SecretAccessPoliciesUpdatesAuthorizationHandler> sutProvider,
AccessClientType accessClientType,
SecretAccessPoliciesUpdates resource,
Guid userId = new(),
bool orgUsersInSameOrg = true,
bool groupsInSameOrg = true,
bool serviceAccountsInSameOrg = true)
{
SetupUserSubstitutes(sutProvider, accessClientType, resource, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(resource.SecretId, userId, accessClientType)
.Returns((true, true));
sutProvider.GetDependency<ISameOrganizationQuery>()
.OrgUsersInTheSameOrgAsync(Arg.Any<List<Guid>>(), resource.OrganizationId)
.Returns(orgUsersInSameOrg);
sutProvider.GetDependency<ISameOrganizationQuery>()
.GroupsInTheSameOrgAsync(Arg.Any<List<Guid>>(), resource.OrganizationId)
.Returns(groupsInSameOrg);
sutProvider.GetDependency<IServiceAccountRepository>()
.ServiceAccountsAreInOrganizationAsync(Arg.Any<List<Guid>>(), resource.OrganizationId)
.Returns(serviceAccountsInSameOrg);
}
private static SecretAccessPoliciesUpdates RemoveAllServiceAccountCreates(
SecretAccessPoliciesUpdates resource)
{
resource.ServiceAccountAccessPolicyUpdates =
resource.ServiceAccountAccessPolicyUpdates.Where(x => x.Operation != AccessPolicyOperation.Create);
return resource;
}
private static SecretAccessPoliciesUpdates SetAllToCreates(
SecretAccessPoliciesUpdates resource)
{
resource.UserAccessPolicyUpdates = resource.UserAccessPolicyUpdates.Select(x =>
{
x.Operation = AccessPolicyOperation.Create;
return x;
});
resource.GroupAccessPolicyUpdates = resource.GroupAccessPolicyUpdates.Select(x =>
{
x.Operation = AccessPolicyOperation.Create;
return x;
});
resource.ServiceAccountAccessPolicyUpdates = resource.ServiceAccountAccessPolicyUpdates.Select(x =>
{
x.Operation = AccessPolicyOperation.Create;
return x;
});
return resource;
}
private static SecretAccessPoliciesUpdates AddServiceAccountCreateUpdate(
SecretAccessPoliciesUpdates resource)
{
resource.ServiceAccountAccessPolicyUpdates = resource.ServiceAccountAccessPolicyUpdates.Append(
new ServiceAccountSecretAccessPolicyUpdate
{
AccessPolicy = new ServiceAccountSecretAccessPolicy
{
ServiceAccountId = Guid.NewGuid(),
GrantedSecretId = resource.SecretId,
Read = true,
Write = true
}
});
return resource;
}
private static SecretAccessPoliciesUpdates ClearAccessPolicyUpdate(SecretAccessPoliciesUpdates resource,
bool orgUsersCountZero,
bool groupsCountZero,
bool serviceAccountsCountZero)
{
if (orgUsersCountZero)
{
resource.UserAccessPolicyUpdates = [];
}
if (groupsCountZero)
{
resource.GroupAccessPolicyUpdates = [];
}
if (serviceAccountsCountZero)
{
resource.ServiceAccountAccessPolicyUpdates = [];
}
return resource;
}
}

View File

@ -0,0 +1,224 @@
#nullable enable
using System.Reflection;
using System.Security.Claims;
using Bit.Commercial.Core.SecretsManager.AuthorizationHandlers.Secrets;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.AuthorizationRequirements;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Queries.Interfaces;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Test.SecretsManager.AutoFixture.ProjectsFixture;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Microsoft.AspNetCore.Authorization;
using NSubstitute;
using Xunit;
namespace Bit.Commercial.Core.Test.SecretsManager.AuthorizationHandlers.Secrets;
[SutProviderCustomize]
[ProjectCustomize]
public class BulkSecretAuthorizationHandlerTests
{
[Fact]
public void BulkSecretOperations_OnlyPublicStatic()
{
var publicStaticFields = typeof(BulkSecretOperations).GetFields(BindingFlags.Public | BindingFlags.Static);
var allFields = typeof(BulkSecretOperations).GetFields();
Assert.Equal(publicStaticFields.Length, allFields.Length);
}
[Theory]
[BitAutoData]
public async Task Handler_MisMatchedOrganizations_DoesNotSucceed(
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = BulkSecretOperations.ReadAll;
resources[0].OrganizationId = Guid.NewGuid();
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(Arg.Any<Guid>())
.ReturnsForAnyArgs(true);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task Handler_NoAccessToSecretsManager_DoesNotSucceed(
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = BulkSecretOperations.ReadAll;
resources = SetSameOrganization(resources);
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(Arg.Any<Guid>())
.ReturnsForAnyArgs(false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task Handler_UnsupportedSecretOperationRequirement_Throws(
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = new BulkSecretOperationRequirement();
resources = SetSameOrganization(resources);
SetupUserSubstitutes(sutProvider, AccessClientType.User, resources.First().OrganizationId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await Assert.ThrowsAsync<ArgumentException>(() => sutProvider.Sut.HandleAsync(authzContext));
}
[Theory]
[BitAutoData(AccessClientType.User)]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.ServiceAccount)]
public async Task Handler_NoAccessToSecrets_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = BulkSecretOperations.ReadAll;
resources = SetSameOrganization(resources);
var secretIds =
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
.Returns(secretIds.ToDictionary(id => id, _ => (false, false)));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.User)]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.ServiceAccount)]
public async Task Handler_HasAccessToSomeSecrets_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = BulkSecretOperations.ReadAll;
resources = SetSameOrganization(resources);
var secretIds =
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
var accessResult = secretIds.ToDictionary(secretId => secretId, _ => (false, false));
accessResult[secretIds.First()] = (true, true);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
.Returns(accessResult);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.User)]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.ServiceAccount)]
public async Task Handler_PartialAccessReturn_DoesNotSucceed(
AccessClientType accessClientType,
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = BulkSecretOperations.ReadAll;
resources = SetSameOrganization(resources);
var secretIds =
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
var accessResult = secretIds.ToDictionary(secretId => secretId, _ => (false, false));
accessResult.Remove(secretIds.First());
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
.Returns(accessResult);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.User)]
[BitAutoData(AccessClientType.NoAccessCheck)]
[BitAutoData(AccessClientType.ServiceAccount)]
public async Task Handler_HasAccessToAllSecrets_Success(
AccessClientType accessClientType,
SutProvider<BulkSecretAuthorizationHandler> sutProvider, List<Secret> resources,
ClaimsPrincipal claimsPrincipal)
{
var requirement = BulkSecretOperations.ReadAll;
resources = SetSameOrganization(resources);
var secretIds =
SetupSecretAccessRequest(sutProvider, resources, accessClientType, resources.First().OrganizationId);
var accessResult = secretIds.ToDictionary(secretId => secretId, _ => (true, true));
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretsAsync(Arg.Any<IEnumerable<Guid>>(), Arg.Any<Guid>(), Arg.Any<AccessClientType>())
.Returns(accessResult);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, resources);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.True(authzContext.HasSucceeded);
}
private static List<Secret> SetSameOrganization(List<Secret> secrets)
{
var organizationId = secrets.First().OrganizationId;
foreach (var secret in secrets)
{
secret.OrganizationId = organizationId;
}
return secrets;
}
private static void SetupUserSubstitutes(
SutProvider<BulkSecretAuthorizationHandler> sutProvider,
AccessClientType accessClientType,
Guid organizationId,
Guid userId = new())
{
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(organizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>().GetAccessClientAsync(default, organizationId)
.ReturnsForAnyArgs((accessClientType, userId));
}
private static List<Guid> SetupSecretAccessRequest(
SutProvider<BulkSecretAuthorizationHandler> sutProvider,
IEnumerable<Secret> resources,
AccessClientType accessClientType,
Guid organizationId,
Guid userId = new())
{
SetupUserSubstitutes(sutProvider, accessClientType, organizationId, userId);
return resources.Select(s => s.Id).ToList();
}
}

View File

@ -352,14 +352,16 @@ public class SecretAuthorizationHandlerTests
[Theory]
[BitAutoData]
public async Task CanUpdateSecret_WithoutProjectUser_DoesNotSucceed(
public async Task CanUpdateSecret_ClearProjectsUser_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
secret.Projects = null;
secret.Projects = [];
var requirement = SecretOperations.Update;
SetupPermission(sutProvider, PermissionType.RunAsUserWithPermission, secret.OrganizationId, userId);
sutProvider.GetDependency<ISecretRepository>().AccessToSecretAsync(secret.Id, Arg.Any<Guid>(), Arg.Any<AccessClientType>()).Returns(
(true, true));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
@ -370,12 +372,12 @@ public class SecretAuthorizationHandlerTests
[Theory]
[BitAutoData]
public async Task CanUpdateSecret_WithoutProjectAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
public async Task CanUpdateSecret_ClearProjectsAdmin_Success(SutProvider<SecretAuthorizationHandler> sutProvider,
Secret secret,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
secret.Projects = null;
secret.Projects = [];
var requirement = SecretOperations.Update;
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
@ -386,6 +388,35 @@ public class SecretAuthorizationHandlerTests
Assert.True(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, false, false, false)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, false, true, true)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, true, false, false)]
[BitAutoData(PermissionType.RunAsServiceAccountWithPermission, true, true, true)]
public async Task CanUpdateSecret_NoProjectChanges_ReturnsExpected(PermissionType permissionType, bool read,
bool write, bool expected,
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
Guid userId,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretOperations.Update;
secret.Projects = null;
SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(secret.Id, userId, Arg.Any<AccessClientType>()).Returns(
(read, write));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.Equal(expected, authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, false, false)]
@ -517,4 +548,85 @@ public class SecretAuthorizationHandlerTests
Assert.Equal(expected, authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task CanReadAccessPolicies_AccessToSecretsManagerFalse_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretOperations.ReadAccessPolicies;
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId)
.Returns(false);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData]
public async Task CanReadAccessPolicies_NullResource_DoesNotSucceed(
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal,
Guid userId)
{
var requirement = SecretOperations.ReadAccessPolicies;
SetupPermission(sutProvider, PermissionType.RunAsAdmin, secret.OrganizationId, userId);
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, null);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(AccessClientType.ServiceAccount)]
[BitAutoData(AccessClientType.Organization)]
public async Task CanReadAccessPolicies_UnsupportedClient_DoesNotSucceed(
AccessClientType clientType,
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal)
{
var requirement = SecretOperations.ReadAccessPolicies;
sutProvider.GetDependency<ICurrentContext>().AccessSecretsManager(secret.OrganizationId)
.Returns(true);
sutProvider.GetDependency<IAccessClientQuery>()
.GetAccessClientAsync(Arg.Any<ClaimsPrincipal>(), secret.OrganizationId)
.Returns((clientType, Guid.NewGuid()));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.False(authzContext.HasSucceeded);
}
[Theory]
[BitAutoData(PermissionType.RunAsAdmin, true, true, true)]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, false, true, true)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, false, false)]
[BitAutoData(PermissionType.RunAsUserWithPermission, true, true, true)]
public async Task CanReadAccessPolicies_AccessCheck(PermissionType permissionType, bool read, bool write,
bool expected,
SutProvider<SecretAuthorizationHandler> sutProvider, Secret secret,
ClaimsPrincipal claimsPrincipal,
Guid userId)
{
var requirement = SecretOperations.ReadAccessPolicies;
SetupPermission(sutProvider, permissionType, secret.OrganizationId, userId);
sutProvider.GetDependency<ISecretRepository>()
.AccessToSecretAsync(secret.Id, userId, Arg.Any<AccessClientType>())
.Returns((read, write));
var authzContext = new AuthorizationHandlerContext(new List<IAuthorizationRequirement> { requirement },
claimsPrincipal, secret);
await sutProvider.Sut.HandleAsync(authzContext);
Assert.Equal(expected, authzContext.HasSucceeded);
}
}

View File

@ -30,7 +30,7 @@ public class CreateProjectCommandTests
.CreateAsync(Arg.Any<Project>())
.Returns(data);
await sutProvider.Sut.CreateAsync(data, userId, sutProvider.GetDependency<ICurrentContext>().ClientType);
await sutProvider.Sut.CreateAsync(data, userId, sutProvider.GetDependency<ICurrentContext>().IdentityClientType);
await sutProvider.GetDependency<IProjectRepository>().Received(1)
.CreateAsync(Arg.Is(data));

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