mirror of
https://github.com/bitwarden/server
synced 2026-02-14 23:45:11 +00:00
Merge branch 'main' of https://github.com/bitwarden/server into quartz-strongly-typed-configuration
This commit is contained in:
77
.claude/CLAUDE.md
Normal file
77
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Bitwarden Server - Claude Code Configuration
|
||||
|
||||
## Project Context Files
|
||||
|
||||
**Read these files before reviewing to ensure that you fully understand the project and contributing guidelines**
|
||||
|
||||
1. @README.md
|
||||
2. @CONTRIBUTING.md
|
||||
3. @.github/PULL_REQUEST_TEMPLATE.md
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **NEVER** use code regions: If complexity suggests regions, refactor for better readability
|
||||
|
||||
- **NEVER** compromise zero-knowledge principles: User vault data must remain encrypted and inaccessible to Bitwarden
|
||||
|
||||
- **NEVER** log or expose sensitive data: No PII, passwords, keys, or vault data in logs or error messages
|
||||
|
||||
- **ALWAYS** use secure communication channels: Enforce confidentiality, integrity, and authenticity
|
||||
|
||||
- **ALWAYS** encrypt sensitive data: All vault data must be encrypted at rest, in transit, and in use
|
||||
|
||||
- **ALWAYS** prioritize cryptographic integrity and data protection
|
||||
|
||||
- **ALWAYS** add unit tests (with mocking) for any new feature development
|
||||
|
||||
## Project Structure
|
||||
|
||||
- **Source Code**: `/src/` - Services and core infrastructure
|
||||
- **Tests**: `/test/` - Test logic aligning with the source structure, albeit with a `.Test` suffix
|
||||
- **Utilities**: `/util/` - Migration tools, seeders, and setup scripts
|
||||
- **Dev Tools**: `/dev/` - Local development helpers
|
||||
- **Configuration**: `appsettings.{Environment}.json`, `/dev/secrets.json` for local development
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- **Compliance**: SOC 2 Type II, SOC 3, HIPAA, ISO 27001, GDPR, CCPA
|
||||
- **Principles**: Zero-knowledge, end-to-end encryption, secure defaults
|
||||
- **Validation**: Input sanitization, parameterized queries, rate limiting
|
||||
- **Logging**: Structured logs, no PII/sensitive data in logs
|
||||
|
||||
## Common Commands
|
||||
|
||||
- **Build**: `dotnet build`
|
||||
- **Test**: `dotnet test`
|
||||
- **Run locally**: `dotnet run --project src/Api`
|
||||
- **Database update**: `pwsh dev/migrate.ps1`
|
||||
- **Generate OpenAPI**: `pwsh dev/generate_openapi_files.ps1`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
- Security impact assessed
|
||||
- xUnit tests added / updated
|
||||
- Performance impact considered
|
||||
- Error handling implemented
|
||||
- Breaking changes documented
|
||||
- CI passes: build, test, lint
|
||||
- Feature flags considered for new features
|
||||
- CODEOWNERS file respected
|
||||
|
||||
### Key Architectural Decisions
|
||||
|
||||
- Use .NET nullable reference types (ADR 0024)
|
||||
- TryAdd dependency injection pattern (ADR 0026)
|
||||
- Authorization patterns (ADR 0022)
|
||||
- OpenTelemetry for observability (ADR 0020)
|
||||
- Log to standard output (ADR 0021)
|
||||
|
||||
## References
|
||||
|
||||
- [Server architecture](https://contributing.bitwarden.com/architecture/server/)
|
||||
- [Architectural Decision Records (ADRs)](https://contributing.bitwarden.com/architecture/adr/)
|
||||
- [Contributing guidelines](https://contributing.bitwarden.com/contributing/)
|
||||
- [Setup guide](https://contributing.bitwarden.com/getting-started/server/guide/)
|
||||
- [Code style](https://contributing.bitwarden.com/contributing/code-style/)
|
||||
- [Bitwarden security whitepaper](https://bitwarden.com/help/bitwarden-security-white-paper/)
|
||||
- [Bitwarden security definitions](https://contributing.bitwarden.com/architecture/security/definitions)
|
||||
25
.claude/prompts/review-code.md
Normal file
25
.claude/prompts/review-code.md
Normal file
@@ -0,0 +1,25 @@
|
||||
Please review this pull request with a focus on:
|
||||
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Security implications
|
||||
- Performance considerations
|
||||
|
||||
Note: The PR branch is already checked out in the current working directory.
|
||||
|
||||
Provide a comprehensive review including:
|
||||
|
||||
- Summary of changes since last review
|
||||
- Critical issues found (be thorough)
|
||||
- Suggested improvements (be thorough)
|
||||
- Good practices observed (be concise - list only the most notable items without elaboration)
|
||||
- Action items for the author
|
||||
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
|
||||
|
||||
When reviewing subsequent commits:
|
||||
|
||||
- Track status of previously identified issues (fixed/unfixed/reopened)
|
||||
- Identify NEW problems introduced since last review
|
||||
- Note if fixes introduced new issues
|
||||
|
||||
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
|
||||
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"swashbuckle.aspnetcore.cli": {
|
||||
"version": "6.7.3",
|
||||
"version": "9.0.4",
|
||||
"commands": ["swagger"]
|
||||
},
|
||||
"dotnet-ef": {
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
bitwarden_server:
|
||||
image: mcr.microsoft.com/devcontainers/dotnet:8.0
|
||||
volumes:
|
||||
- ../../:/workspace:cached
|
||||
env_file:
|
||||
- path: ../../dev/.env
|
||||
required: false
|
||||
# Overrides default command so things don't shut down after the process ends.
|
||||
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 +32,4 @@ services:
|
||||
network_mode: service:bitwarden_server
|
||||
|
||||
volumes:
|
||||
edgesql_dev_data:
|
||||
mssql_dev_data:
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
"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",
|
||||
@@ -13,7 +18,6 @@
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {},
|
||||
"features": {},
|
||||
"extensions": ["ms-dotnettools.csdevkit"]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
],
|
||||
"service": "bitwarden_server",
|
||||
"workspaceFolder": "/workspace",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"version": "16"
|
||||
}
|
||||
},
|
||||
"mounts": [
|
||||
{
|
||||
"source": "../../dev/.data/keys",
|
||||
@@ -16,15 +21,39 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
bitwarden_storage:
|
||||
image: mcr.microsoft.com/azure-storage/azurite:latest
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
export DEV_DIR=/workspace/dev
|
||||
export REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
export CONTAINER_CONFIG=/workspace/.devcontainer/internal_dev
|
||||
|
||||
git config --global --add safe.directory /workspace
|
||||
|
||||
get_installation_id_and_key() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
echo "Please enter your installation id and key from https://bitwarden.com/host:"
|
||||
read -r -p "Installation id: " INSTALLATION_ID
|
||||
read -r -p "Installation key: " INSTALLATION_KEY
|
||||
jq ".globalSettings.installation.id = \"$INSTALLATION_ID\" |
|
||||
.globalSettings.installation.key = \"$INSTALLATION_KEY\"" \
|
||||
secrets.json.example >secrets.json # create/overwrite secrets.json
|
||||
popd >/dev/null || exit
|
||||
if [[ -z "${CODESPACES}" ]]; then
|
||||
allow_interactive=1
|
||||
else
|
||||
echo "Doing non-interactive setup"
|
||||
allow_interactive=0
|
||||
fi
|
||||
|
||||
get_option() {
|
||||
# Helper function for reading the value of an environment variable
|
||||
# primarily but then falling back to an interactive question if allowed
|
||||
# and lastly falling back to a default value input when either other
|
||||
# option is available.
|
||||
name_of_var="$1"
|
||||
question_text="$2"
|
||||
default_value="$3"
|
||||
is_secret="$4"
|
||||
|
||||
if [[ -n "${!name_of_var}" ]]; then
|
||||
# If the env variable they gave us has a value, then use that value
|
||||
echo "${!name_of_var}"
|
||||
elif [[ "$allow_interactive" == 1 ]]; then
|
||||
# If we can be interactive, then use the text they gave us to request input
|
||||
if [[ "$is_secret" == 1 ]]; then
|
||||
read -r -s -p "$question_text" response
|
||||
echo "$response"
|
||||
else
|
||||
read -r -p "$question_text" response
|
||||
echo "$response"
|
||||
fi
|
||||
else
|
||||
# If no environment variable and not interactive, then just give back default value
|
||||
echo "$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_comments() {
|
||||
@@ -26,51 +51,85 @@ remove_comments() {
|
||||
|
||||
configure_other_vars() {
|
||||
pushd ./dev >/dev/null || exit
|
||||
cp secrets.json .secrets.json.tmp
|
||||
cp "$REPO_ROOT/dev/secrets.json" "$REPO_ROOT/dev/.secrets.json.tmp"
|
||||
# set DB_PASSWORD equal to .services.mssql.environment.MSSQL_SA_PASSWORD, accounting for quotes
|
||||
DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $DEV_DIR/.env)"
|
||||
DB_PASSWORD="$(grep -oP 'MSSQL_SA_PASSWORD=["'"'"']?\K[^"'"'"'\s]+' $REPO_ROOT/dev/.env)"
|
||||
SQL_CONNECTION_STRING="Server=localhost;Database=vault_dev;User Id=SA;Password=$DB_PASSWORD;Encrypt=True;TrustServerCertificate=True"
|
||||
jq \
|
||||
".globalSettings.sqlServer.connectionString = \"$SQL_CONNECTION_STRING\" |
|
||||
.globalSettings.postgreSql.connectionString = \"Host=localhost;Username=postgres;Password=$DB_PASSWORD;Database=vault_dev;Include Error Detail=true\" |
|
||||
.globalSettings.mySql.connectionString = \"server=localhost;uid=root;pwd=$DB_PASSWORD;database=vault_dev\"" \
|
||||
.secrets.json.tmp >secrets.json
|
||||
rm .secrets.json.tmp
|
||||
rm "$REPO_ROOT/dev/.secrets.json.tmp"
|
||||
popd >/dev/null || exit
|
||||
}
|
||||
|
||||
one_time_setup() {
|
||||
read -r -p \
|
||||
"Would you like to configure your secrets and certificates for the first time?
|
||||
WARNING: This will overwrite any existing secrets.json and certificate files.
|
||||
Proceed? [y/N] " response
|
||||
if [[ "$response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running one-time setup script..."
|
||||
sleep 1
|
||||
read -r -p \
|
||||
"Place the secrets.json and dev.pfx files from our shared Collection in the ./dev directory.
|
||||
if [[ ! -f "$REPO_ROOT/dev/dev.pfx" ]]; then
|
||||
# We do not have the cert file
|
||||
if [[ ! -z "${DEV_CERT_CONTENTS}" ]]; then
|
||||
# Make file for them
|
||||
echo "Making $REPO_ROOT/dev/dev.pfx file for you based on DEV_CERT_CONTENTS environment variable."
|
||||
# Assume content is base64 encoded
|
||||
echo "$DEV_CERT_CONTENTS" | base64 -d > "$REPO_ROOT/dev/dev.pfx"
|
||||
else
|
||||
if [[ $allow_interactive -eq 1 ]]; then
|
||||
read -r -p \
|
||||
"Place the dev.pfx files from our shared Collection in the $REPO_ROOT/dev directory.
|
||||
Press <Enter> to continue."
|
||||
remove_comments ./dev/secrets.json
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f "$REPO_ROOT/dev/dev.pfx" ]]; then
|
||||
dotnet tool install dotnet-certificate-tool -g >/dev/null
|
||||
cert_password="$(get_option "DEV_CERT_PASSWORD" "Paste the \"Licensing Certificate - Dev\" password: " "" 1)"
|
||||
certificate-tool add --file "$REPO_ROOT/dev/dev.pfx" --password "$cert_password"
|
||||
else
|
||||
echo "You don't have a $REPO_ROOT/dev/dev.pfx file setup." >/dev/stderr
|
||||
fi
|
||||
|
||||
do_secrets_json_setup="$(get_option "SETUP_SECRETS_JSON" "Would you like us to setup your secrets.json file for you? [y/N] " "n")"
|
||||
if [[ "$do_secrets_json_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
remove_comments "$REPO_ROOT/dev/secrets.json"
|
||||
configure_other_vars
|
||||
# setup_secrets needs to be ran from the dev folder
|
||||
pushd "$REPO_ROOT/dev" >/dev/null || exit
|
||||
echo "Injecting dotnet secrets..."
|
||||
pwsh "$REPO_ROOT/dev/setup_secrets.ps1" || true
|
||||
popd >/dev/null || exit
|
||||
fi
|
||||
|
||||
do_azurite_setup="$(get_option "SETUP_AZURITE" "Would you like us to setup your azurite environment? [y/N] " "n")"
|
||||
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Installing Az module. This will take ~a minute..."
|
||||
pwsh -Command "Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force"
|
||||
pwsh ./dev/setup_azurite.ps1
|
||||
|
||||
dotnet tool install dotnet-certificate-tool -g >/dev/null
|
||||
|
||||
read -r -s -p "Paste the \"Licensing Certificate - Dev\" password: " CERT_PASSWORD
|
||||
echo
|
||||
pushd ./dev >/dev/null || exit
|
||||
certificate-tool add --file ./dev.pfx --password "$CERT_PASSWORD"
|
||||
echo "Injecting dotnet secrets..."
|
||||
pwsh ./setup_secrets.ps1 || true
|
||||
popd >/dev/null || exit
|
||||
pwsh "$REPO_ROOT/dev/setup_azurite.ps1"
|
||||
fi
|
||||
|
||||
run_mssql_migrations="$(get_option "RUN_MSSQL_MIGRATIONS" "Would you like us to run MSSQL Migrations for you? [y/N] " "n")"
|
||||
if [[ "$do_azurite_setup" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
echo "Running migrations..."
|
||||
sleep 5 # wait for DB container to start
|
||||
dotnet run --project ./util/MsSqlMigratorUtility "$SQL_CONNECTION_STRING"
|
||||
dotnet run --project "$REPO_ROOT/util/MsSqlMigratorUtility" "$SQL_CONNECTION_STRING"
|
||||
fi
|
||||
|
||||
stripe_response="$(get_option "INSTALL_STRIPE_CLI" "Would you like to install the Stripe CLI? [y/N] " "n")"
|
||||
if [[ "$stripe_response" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
|
||||
install_stripe_cli
|
||||
fi
|
||||
}
|
||||
|
||||
# main
|
||||
# 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
|
||||
}
|
||||
|
||||
one_time_setup
|
||||
|
||||
@@ -123,3 +123,12 @@ csharp_style_namespace_declarations = file_scoped:warning
|
||||
# Switch expression
|
||||
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
|
||||
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value
|
||||
|
||||
# CA2253: Named placeholders should nto be numeric values
|
||||
dotnet_diagnostic.CA2253.severity = suggestion
|
||||
|
||||
# CA2254: Template should be a static expression
|
||||
dotnet_diagnostic.CA2254.severity = warning
|
||||
|
||||
# CA1727: Use PascalCase for named placeholders
|
||||
dotnet_diagnostic.CA1727.severity = suggestion
|
||||
|
||||
66
.github/CODEOWNERS
vendored
66
.github/CODEOWNERS
vendored
@@ -4,31 +4,56 @@
|
||||
#
|
||||
# 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-related files
|
||||
**/Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/*.Dockerfile @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/*.dockerignore @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/docker-compose.yml @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
**/entrypoint.sh @bitwarden/team-appsec @bitwarden/dept-bre
|
||||
|
||||
# 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_edd_db_scripts.yml
|
||||
.github/workflows/release.yml
|
||||
|
||||
# Database Operations for database changes
|
||||
src/Sql/** @bitwarden/dept-dbops
|
||||
util/EfShared/** @bitwarden/dept-dbops
|
||||
util/Migrator/** @bitwarden/dept-dbops
|
||||
util/Migrator/** @bitwarden/team-platform-dev # The Platform team owns the Migrator project code
|
||||
util/Migrator/DbScripts/** @bitwarden/dept-dbops
|
||||
util/Migrator/DbScripts_finalization/** @bitwarden/dept-dbops
|
||||
util/Migrator/DbScripts_transition/** @bitwarden/dept-dbops
|
||||
util/Migrator/MySql/** @bitwarden/dept-dbops
|
||||
util/MySqlMigrations/** @bitwarden/dept-dbops
|
||||
util/PostgresMigrations/** @bitwarden/dept-dbops
|
||||
util/SqlServerEFScaffold/** @bitwarden/dept-dbops
|
||||
util/SqliteMigrations/** @bitwarden/dept-dbops
|
||||
|
||||
# Shared util projects
|
||||
util/Setup/** @bitwarden/dept-bre @bitwarden/team-platform-dev
|
||||
|
||||
# UIF
|
||||
src/Core/MailTemplates/Mjml @bitwarden/team-ui-foundation # Teams are expected to own sub-directories of this project
|
||||
src/Core/MailTemplates/Mjml/.mjmlconfig # This change allows teams to add components within their own subdirectories without requiring a code review from UIF.
|
||||
|
||||
# Auth team
|
||||
**/Auth @bitwarden/team-auth-dev
|
||||
bitwarden_license/src/Sso @bitwarden/team-auth-dev
|
||||
src/Identity @bitwarden/team-auth-dev
|
||||
src/Core/Identity @bitwarden/team-auth-dev
|
||||
src/Core/IdentityServer @bitwarden/team-auth-dev
|
||||
|
||||
**/SecretsManager @bitwarden/team-secrets-manager-dev
|
||||
# Key Management team
|
||||
**/KeyManagement @bitwarden/team-key-management-dev
|
||||
|
||||
# Tools team
|
||||
**/Tools @bitwarden/team-tools-dev
|
||||
|
||||
# Dirt (Data Insights & Reporting) team
|
||||
**/Dirt @bitwarden/team-data-insights-and-reporting-dev
|
||||
|
||||
# Vault team
|
||||
**/Vault @bitwarden/team-vault-dev
|
||||
**/Vault/AuthorizationHandlers @bitwarden/team-vault-dev @bitwarden/team-admin-console-dev # joint ownership over authorization handlers that affect organization users
|
||||
@@ -57,6 +82,29 @@ src/EventsProcessor @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/build_target.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
|
||||
**/.dockerignore @bitwarden/team-platform-dev
|
||||
**/Dockerfile @bitwarden/team-platform-dev
|
||||
**/entrypoint.sh @bitwarden/team-platform-dev
|
||||
# The PushType enum is expected to be editted by anyone without need for Platform review
|
||||
src/Core/Platform/Push/PushType.cs
|
||||
|
||||
# SDK
|
||||
util/RustSdk @bitwarden/team-sdk-sme
|
||||
|
||||
# Multiple owners - DO NOT REMOVE (BRE)
|
||||
**/packages.lock.json
|
||||
Directory.Build.props
|
||||
|
||||
# Claude related files
|
||||
.claude/ @bitwarden/team-ai-sme
|
||||
.github/workflows/respond.yml @bitwarden/team-ai-sme
|
||||
.github/workflows/review-code.yml @bitwarden/team-ai-sme
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
name: Bitwarden Unified Bug Report
|
||||
name: Bitwarden Unified Deployment Bug Report
|
||||
name: Bitwarden Lite Deployment Bug Report
|
||||
description: File a bug report
|
||||
labels: [bug, bw-unified-deploy]
|
||||
labels: [bug, bw-lite-deploy]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -75,7 +74,7 @@ body:
|
||||
id: epic-label
|
||||
attributes:
|
||||
label: Issue-Link
|
||||
description: Link to our pinned issue, tracking all Bitwarden Unified
|
||||
description: Link to our pinned issue, tracking all Bitwarden Lite
|
||||
value: |
|
||||
https://github.com/bitwarden/server/issues/2480
|
||||
validations:
|
||||
197
.github/renovate.json
vendored
197
.github/renovate.json
vendored
@@ -1,197 +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": [
|
||||
"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.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.Azure.Cosmos",
|
||||
"Microsoft.Data.SqlClient",
|
||||
"Microsoft.EntityFrameworkCore.Design",
|
||||
"Microsoft.EntityFrameworkCore.InMemory",
|
||||
"Microsoft.EntityFrameworkCore.Relational",
|
||||
"Microsoft.EntityFrameworkCore.Sqlite",
|
||||
"Microsoft.EntityFrameworkCore.SqlServer",
|
||||
"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] 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"]
|
||||
}
|
||||
195
.github/renovate.json5
vendored
Normal file
195
.github/renovate.json5
vendored
Normal file
@@ -0,0 +1,195 @@
|
||||
{
|
||||
$schema: "https://docs.renovatebot.com/renovate-schema.json",
|
||||
extends: ["github>bitwarden/renovate-config"], // Extends our default configuration for pinned dependencies
|
||||
enabledManagers: [
|
||||
"cargo",
|
||||
"dockerfile",
|
||||
"docker-compose",
|
||||
"github-actions",
|
||||
"npm",
|
||||
"nuget",
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
groupName: "cargo minor",
|
||||
matchManagers: ["cargo"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "dockerfile minor",
|
||||
matchManagers: ["dockerfile"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "docker-compose minor",
|
||||
matchManagers: ["docker-compose"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
},
|
||||
{
|
||||
groupName: "github-action minor",
|
||||
matchManagers: ["github-actions"],
|
||||
matchUpdateTypes: ["minor"],
|
||||
addLabels: ["hold"],
|
||||
},
|
||||
{
|
||||
// For any Microsoft.Extensions.* and Microsoft.AspNetCore.* packages, we want to create PRs for patch updates.
|
||||
// This overrides the default that ignores patch updates for nuget dependencies.
|
||||
matchPackageNames: [
|
||||
"/^Microsoft\\.Extensions\\./",
|
||||
"/^Microsoft\\.AspNetCore\\./",
|
||||
],
|
||||
matchUpdateTypes: ["patch"],
|
||||
dependencyDashboardApproval: false,
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["https://github.com/bitwarden/sdk-internal.git"],
|
||||
groupName: "sdk-internal",
|
||||
},
|
||||
{
|
||||
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: [
|
||||
"DuoUniversal",
|
||||
"Fido2.AspNet",
|
||||
"Duende.IdentityServer",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer",
|
||||
"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",
|
||||
"Newtonsoft.Json",
|
||||
"NSubstitute",
|
||||
"Serilog.Extensions.Logging.File",
|
||||
"Stripe.net",
|
||||
"Swashbuckle.AspNetCore",
|
||||
"Swashbuckle.AspNetCore.SwaggerGen",
|
||||
"xunit",
|
||||
"xunit.runner.visualstudio",
|
||||
],
|
||||
description: "Billing owned dependencies",
|
||||
commitMessagePrefix: "[deps] Billing:",
|
||||
reviewers: ["team:team-billing-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["/^Microsoft\\.EntityFrameworkCore\\./", "/^dotnet-ef/"],
|
||||
groupName: "EntityFrameworkCore",
|
||||
description: "Group EntityFrameworkCore to exclude them from the dotnet monorepo preset",
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"Dapper",
|
||||
"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",
|
||||
"Npgsql.EntityFrameworkCore.PostgreSQL",
|
||||
"Pomelo.EntityFrameworkCore.MySql",
|
||||
],
|
||||
description: "DbOps owned dependencies",
|
||||
commitMessagePrefix: "[deps] DbOps:",
|
||||
reviewers: ["team:dept-dbops"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: ["YamlDotNet"],
|
||||
description: "BRE owned dependencies",
|
||||
commitMessagePrefix: "[deps] BRE:",
|
||||
reviewers: ["team:dept-bre"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AspNetCoreRateLimit",
|
||||
"AspNetCoreRateLimit.Redis",
|
||||
"Azure.Data.Tables",
|
||||
"Azure.Extensions.AspNetCore.DataProtection.Blobs",
|
||||
"Azure.Messaging.EventGrid",
|
||||
"Azure.Messaging.ServiceBus",
|
||||
"Azure.Storage.Blobs",
|
||||
"Azure.Storage.Queues",
|
||||
"LaunchDarkly.ServerSdk",
|
||||
"Microsoft.AspNetCore.Http",
|
||||
"Microsoft.AspNetCore.SignalR.Protocols.MessagePack",
|
||||
"Microsoft.AspNetCore.SignalR.StackExchangeRedis",
|
||||
"Microsoft.Extensions.Configuration.EnvironmentVariables",
|
||||
"Microsoft.Extensions.Configuration.UserSecrets",
|
||||
"Microsoft.Extensions.Configuration",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions",
|
||||
"Microsoft.Extensions.DependencyInjection",
|
||||
"Microsoft.Extensions.Logging",
|
||||
"Microsoft.Extensions.Logging.Console",
|
||||
"Microsoft.Extensions.Caching.Cosmos",
|
||||
"Microsoft.Extensions.Caching.SqlServer",
|
||||
"Microsoft.Extensions.Caching.StackExchangeRedis",
|
||||
"Quartz",
|
||||
],
|
||||
description: "Platform owned dependencies",
|
||||
commitMessagePrefix: "[deps] Platform:",
|
||||
reviewers: ["team:team-platform-dev"],
|
||||
},
|
||||
{
|
||||
matchPackageNames: [
|
||||
"AutoMapper.Extensions.Microsoft.DependencyInjection",
|
||||
"AWSSDK.SimpleEmail",
|
||||
"AWSSDK.SQS",
|
||||
"Handlebars.Net",
|
||||
"MailKit",
|
||||
"Microsoft.Azure.NotificationHubs",
|
||||
"SendGrid",
|
||||
],
|
||||
description: "Tools owned dependencies",
|
||||
commitMessagePrefix: "[deps] Tools:",
|
||||
reviewers: ["team:team-tools-dev"],
|
||||
},
|
||||
{
|
||||
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"],
|
||||
}
|
||||
211
.github/workflows/_move_edd_db_scripts.yml
vendored
Normal file
211
.github/workflows/_move_edd_db_scripts.yml
vendored
Normal file
@@ -0,0 +1,211 @@
|
||||
name: _move_edd_db_scripts
|
||||
run-name: Move EDD database scripts
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
|
||||
copy_edd_scripts: ${{ steps.check-script-existence.outputs.copy_edd_scripts }}
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get script prefix
|
||||
id: prefix
|
||||
run: echo "prefix=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if any files in DB transition or finalization directories
|
||||
id: check-script-existence
|
||||
run: |
|
||||
if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then
|
||||
echo "copy_edd_scripts=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "copy_edd_scripts=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
move-scripts:
|
||||
name: Move scripts
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
actions: read
|
||||
if: ${{ needs.setup.outputs.copy_edd_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: true
|
||||
|
||||
- name: Generate branch name
|
||||
id: branch_name
|
||||
env:
|
||||
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
|
||||
run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Create branch"
|
||||
env:
|
||||
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
|
||||
run: git switch -c "$BRANCH"
|
||||
|
||||
- name: Move scripts and finalization database schema
|
||||
id: move-files
|
||||
env:
|
||||
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
|
||||
run: |
|
||||
# scripts
|
||||
moved_files="Migration scripts moved:\n\n"
|
||||
|
||||
src_dirs="util/Migrator/DbScripts_transition,util/Migrator/DbScripts_finalization"
|
||||
dest_dir="util/Migrator/DbScripts"
|
||||
i=0
|
||||
|
||||
for src_dir in ${src_dirs//,/ }; do
|
||||
for file in "$src_dir"/*; do
|
||||
filenumber=$(printf "%02d" $i)
|
||||
|
||||
filename=$(basename "$file")
|
||||
new_filename="${PREFIX}_${filenumber}_${filename}"
|
||||
dest_file="$dest_dir/$new_filename"
|
||||
|
||||
# Replace any finalization references due to the move
|
||||
sed -i -e 's/dbo_finalization/dbo/g' "$file"
|
||||
|
||||
mv "$file" "$dest_file"
|
||||
moved_files="$moved_files \n $filename -> $new_filename"
|
||||
|
||||
i=$((i+1))
|
||||
done
|
||||
done
|
||||
|
||||
# schema
|
||||
moved_files="$moved_files\n\nFinalization scripts moved:\n\n"
|
||||
|
||||
src_dir="src/Sql/dbo_finalization"
|
||||
dest_dir="src/Sql/dbo"
|
||||
|
||||
# sync finalization schema back to dbo, maintaining structure
|
||||
rsync -r "$src_dir/" "$dest_dir/"
|
||||
rm -rf "${src_dir}"/*
|
||||
|
||||
# Replace any finalization references due to the move
|
||||
find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \
|
||||
-e 's/\[dbo_finalization\]/[dbo]/g' \
|
||||
-e 's/dbo_finalization\./dbo./g' {} +
|
||||
|
||||
for file in "$src_dir"/**/*; do
|
||||
moved_files="$moved_files \n $file"
|
||||
done
|
||||
|
||||
echo "moved_files=$moved_files" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: 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,
|
||||
devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Import GPG keys
|
||||
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6.3.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: Commit and push changes
|
||||
id: commit
|
||||
env:
|
||||
BRANCH_NAME: ${{ steps.branch_name.outputs.branch_name }}
|
||||
run: |
|
||||
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
|
||||
git config --local user.name "bitwarden-devops-bot"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add .
|
||||
git commit -m "Move EDD database scripts" -a
|
||||
git push -u origin "${BRANCH_NAME}"
|
||||
echo "pr_needed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "No changes to commit!";
|
||||
echo "pr_needed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "### :mega: No changes to commit! PR was ommited." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Create PR for ${{ steps.branch_name.outputs.branch_name }}
|
||||
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
|
||||
id: create-pr
|
||||
env:
|
||||
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
|
||||
TITLE: "Move EDD database scripts"
|
||||
run: |
|
||||
PR_URL=$(gh pr create --title "$TITLE" \
|
||||
--base "main" \
|
||||
--head "$BRANCH" \
|
||||
--label "automated pr" \
|
||||
--body "
|
||||
Automated movement of EDD database scripts.
|
||||
|
||||
Files moved:
|
||||
$(echo -e "$MOVED_FILES")
|
||||
")
|
||||
echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify Slack about creation of PR
|
||||
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
message: "Created PR for moving EDD database scripts: ${{ steps.create-pr.outputs.pr_url }}"
|
||||
status: ${{ job.status }}
|
||||
162
.github/workflows/_move_finalization_db_scripts.yml
vendored
162
.github/workflows/_move_finalization_db_scripts.yml
vendored
@@ -1,162 +0,0 @@
|
||||
---
|
||||
name: _move_finalization_db_scripts
|
||||
run-name: Move finalization database scripts
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
|
||||
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
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-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
|
||||
- name: Get script prefix
|
||||
id: prefix
|
||||
run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check if any files in DB finalization directory
|
||||
id: check-finalization-scripts-existence
|
||||
run: |
|
||||
if [ -f util/Migrator/DbScripts_finalization/* ]; then
|
||||
echo "copy_finalization_scripts=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "copy_finalization_scripts=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
move-finalization-db-scripts:
|
||||
name: Move finalization database scripts
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate branch name
|
||||
id: branch_name
|
||||
env:
|
||||
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
|
||||
run: echo "branch_name=move_finalization_db_scripts_$PREFIX" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Create branch"
|
||||
env:
|
||||
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
|
||||
run: git switch -c $BRANCH
|
||||
|
||||
- name: Move DbScripts_finalization
|
||||
id: move-files
|
||||
env:
|
||||
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
|
||||
run: |
|
||||
src_dir="util/Migrator/DbScripts_finalization"
|
||||
dest_dir="util/Migrator/DbScripts"
|
||||
i=0
|
||||
|
||||
moved_files=""
|
||||
for file in "$src_dir"/*; do
|
||||
filenumber=$(printf "%02d" $i)
|
||||
|
||||
filename=$(basename "$file")
|
||||
new_filename="${PREFIX}_${filenumber}_${filename}"
|
||||
dest_file="$dest_dir/$new_filename"
|
||||
|
||||
mv "$file" "$dest_file"
|
||||
moved_files="$moved_files \n $filename -> $new_filename"
|
||||
|
||||
i=$((i+1))
|
||||
done
|
||||
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
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,
|
||||
devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Import GPG keys
|
||||
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.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: Commit and push changes
|
||||
id: commit
|
||||
run: |
|
||||
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
|
||||
git config --local user.name "bitwarden-devops-bot"
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
git add .
|
||||
git commit -m "Move DbScripts_finalization to DbScripts" -a
|
||||
git push -u origin ${{ steps.branch_name.outputs.branch_name }}
|
||||
echo "pr_needed=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "No changes to commit!";
|
||||
echo "pr_needed=false" >> $GITHUB_OUTPUT
|
||||
echo "### :mega: No changes to commit! PR was ommited." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Create PR for ${{ steps.branch_name.outputs.branch_name }}
|
||||
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
|
||||
id: create-pr
|
||||
env:
|
||||
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
|
||||
TITLE: "Move finalization database scripts"
|
||||
run: |
|
||||
PR_URL=$(gh pr create --title "$TITLE" \
|
||||
--base "main" \
|
||||
--head "$BRANCH" \
|
||||
--label "automated pr" \
|
||||
--body "
|
||||
## Automated movement of DbScripts_finalization to DbScripts
|
||||
|
||||
## Files moved:
|
||||
$(echo -e "$MOVED_FILES")
|
||||
")
|
||||
echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Notify Slack about creation of PR
|
||||
if: ${{ steps.commit.outputs.pr_needed == 'true' }}
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
message: "Created PR for moving DbScripts_finalization to DbScripts: ${{ steps.create-pr.outputs.pr_url }}"
|
||||
status: ${{ job.status }}
|
||||
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Automatic responses
|
||||
on:
|
||||
issues:
|
||||
|
||||
521
.github/workflows/build.yml
vendored
521
.github/workflows/build.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Build
|
||||
|
||||
on:
|
||||
@@ -9,9 +8,16 @@ on:
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
workflow_call:
|
||||
inputs: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
_GITHUB_PR_REPO_NAME: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -19,109 +25,28 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Verify format
|
||||
run: dotnet format --verify-no-changes
|
||||
|
||||
build-artifacts:
|
||||
name: Build artifacts
|
||||
name: Build Docker images
|
||||
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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
|
||||
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@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
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
|
||||
outputs:
|
||||
has_secrets: ${{ steps.check-secrets.outputs.has_secrets }}
|
||||
permissions:
|
||||
security-events: write
|
||||
needs: build-artifacts
|
||||
id-token: write
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -129,6 +54,7 @@ jobs:
|
||||
- project_name: Admin
|
||||
base_path: ./src
|
||||
dotnet: true
|
||||
node: true
|
||||
- project_name: Api
|
||||
base_path: ./src
|
||||
dotnet: true
|
||||
@@ -162,9 +88,6 @@ jobs:
|
||||
- project_name: Scim
|
||||
base_path: ./bitwarden_license/src
|
||||
dotnet: true
|
||||
- project_name: Server
|
||||
base_path: ./util
|
||||
dotnet: true
|
||||
- project_name: Setup
|
||||
base_path: ./util
|
||||
dotnet: true
|
||||
@@ -172,36 +95,96 @@ jobs:
|
||||
base_path: ./bitwarden_license/src
|
||||
dotnet: true
|
||||
steps:
|
||||
- name: Check secrets
|
||||
id: check-secrets
|
||||
run: |
|
||||
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
|
||||
echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check branch to publish
|
||||
env:
|
||||
PUBLISH_BRANCHES: "main,rc,hotfix-rc"
|
||||
id: publish-branch-check
|
||||
run: |
|
||||
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES
|
||||
|
||||
IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES"
|
||||
if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then
|
||||
echo "is_publish_branch=true" >> $GITHUB_ENV
|
||||
echo "is_publish_branch=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "is_publish_branch=false" >> $GITHUB_ENV
|
||||
echo "is_publish_branch=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
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 }}
|
||||
if: ${{ matrix.dotnet }}
|
||||
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
if: ${{ matrix.dotnet }}
|
||||
with:
|
||||
name: ${{ matrix.project_name }}.zip
|
||||
path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
|
||||
if-no-files-found: error
|
||||
|
||||
########## Set up Docker ##########
|
||||
- name: Set up QEMU emulators
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
|
||||
########## ACRs ##########
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to 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
|
||||
@@ -213,18 +196,24 @@ jobs:
|
||||
- name: Generate Docker image tag
|
||||
id: tag
|
||||
run: |
|
||||
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
|
||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request" || "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize branch name to alphanumeric only
|
||||
else
|
||||
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
|
||||
fi
|
||||
|
||||
if [[ "${{ github.event.pull_request.head.repo.fork }}" == "true" ]]; then
|
||||
SANITIZED_REPO_NAME=$(echo "$_GITHUB_PR_REPO_NAME" | sed "s/[^a-zA-Z0-9]/-/g") # Sanitize repo name to alphanumeric only
|
||||
IMAGE_TAG=$SANITIZED_REPO_NAME-$IMAGE_TAG # Add repo name to the tag
|
||||
IMAGE_TAG=${IMAGE_TAG:0:128} # Limit to 128 characters, as that's the max length for Docker image tags
|
||||
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
|
||||
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Set up project name
|
||||
id: setup
|
||||
@@ -232,7 +221,7 @@ jobs:
|
||||
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
|
||||
echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Generate image tags(s)
|
||||
id: image-tags
|
||||
@@ -242,76 +231,98 @@ jobs:
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}"
|
||||
echo "primary_tag=$TAGS" >> $GITHUB_OUTPUT
|
||||
echo "primary_tag=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
if [[ "${IMAGE_TAG}" == "dev" ]]; then
|
||||
SHORT_SHA=$(git rev-parse --short ${SHA})
|
||||
SHORT_SHA=$(git rev-parse --short "${SHA}")
|
||||
TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}"
|
||||
fi
|
||||
echo "tags=$TAGS" >> $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
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@5cd11c3a4ced054e52742c5fd54dca954e0edd85 # v6.7.0
|
||||
id: build-artifacts
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
context: ${{ matrix.base_path }}/${{ matrix.project_name }}
|
||||
context: .
|
||||
file: ${{ matrix.base_path }}/${{ matrix.project_name }}/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: |
|
||||
linux/amd64,
|
||||
linux/arm/v7,
|
||||
linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.image-tags.outputs.tags }}
|
||||
secrets: |
|
||||
"GH_PAT=${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}"
|
||||
|
||||
- name: Install Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2
|
||||
|
||||
- name: Sign image with Cosign
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
DIGEST: ${{ steps.build-artifacts.outputs.digest }}
|
||||
TAGS: ${{ steps.image-tags.outputs.tags }}
|
||||
run: |
|
||||
IFS=',' read -r -a tags_array <<< "${TAGS}"
|
||||
images=()
|
||||
for tag in "${tags_array[@]}"; do
|
||||
images+=("${tag}@${DIGEST}")
|
||||
done
|
||||
cosign sign --yes ${images[@]}
|
||||
echo "images=${images[*]}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Scan Docker image
|
||||
id: container-scan
|
||||
uses: anchore/scan-action@64a33b277ea7a1215a3c142735a1091341939ff5 # v4.1.2
|
||||
uses: anchore/scan-action@f6601287cdb1efc985d6b765bbf99cb4c0ac29d8 # v7.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@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
|
||||
uses: github/codeql-action/upload-sarif@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
|
||||
with:
|
||||
sarif_file: ${{ steps.container-scan.outputs.sarif }}
|
||||
sha: ${{ contains(github.event_name, 'pull_request') && github.event.pull_request.head.sha || github.sha }}
|
||||
ref: ${{ contains(github.event_name, 'pull_request') && format('refs/pull/{0}/head', github.event.pull_request.number) || github.ref }}
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
upload:
|
||||
name: Upload
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-docker
|
||||
needs: build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to ACR - production subscription
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
|
||||
|
||||
- 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'
|
||||
&& (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"
|
||||
;;
|
||||
@@ -326,134 +337,81 @@ jobs:
|
||||
STUB_OUTPUT=$(pwd)/docker-stub
|
||||
|
||||
# Run setup
|
||||
docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \
|
||||
dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US
|
||||
docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \
|
||||
dotnet Setup.dll -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU
|
||||
docker run -i --rm --name setup -v "$STUB_OUTPUT/US:/bitwarden" "$SETUP_IMAGE" \
|
||||
/app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US
|
||||
docker run -i --rm --name setup -v "$STUB_OUTPUT/EU:/bitwarden" "$SETUP_IMAGE" \
|
||||
/app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU
|
||||
|
||||
sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT
|
||||
sudo chown -R "$(whoami):$(whoami)" "$STUB_OUTPUT"
|
||||
|
||||
# Remove extra directories and files
|
||||
rm -rf $STUB_OUTPUT/US/letsencrypt
|
||||
rm -rf $STUB_OUTPUT/EU/letsencrypt
|
||||
rm $STUB_OUTPUT/US/env/uid.env $STUB_OUTPUT/US/config.yml
|
||||
rm $STUB_OUTPUT/EU/env/uid.env $STUB_OUTPUT/EU/config.yml
|
||||
rm -rf "$STUB_OUTPUT/US/letsencrypt"
|
||||
rm -rf "$STUB_OUTPUT/EU/letsencrypt"
|
||||
rm "$STUB_OUTPUT/US/env/uid.env" "$STUB_OUTPUT/US/config.yml"
|
||||
rm "$STUB_OUTPUT/EU/env/uid.env" "$STUB_OUTPUT/EU/config.yml"
|
||||
|
||||
# Create uid environment files
|
||||
touch $STUB_OUTPUT/US/env/uid.env
|
||||
touch $STUB_OUTPUT/EU/env/uid.env
|
||||
touch "$STUB_OUTPUT/US/env/uid.env"
|
||||
touch "$STUB_OUTPUT/EU/env/uid.env"
|
||||
|
||||
# Zip up the Docker stub files
|
||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../..
|
||||
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../..
|
||||
cd docker-stub/US; zip -r ../../docker-stub-US.zip ./*; cd ../..
|
||||
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'
|
||||
run: |
|
||||
sha256sum docker-stub-US.zip > docker-stub-US-sha256.txt
|
||||
sha256sum docker-stub-EU.zip > docker-stub-EU-sha256.txt
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- 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@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
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@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
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@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
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@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
with:
|
||||
name: docker-stub-EU-sha256.txt
|
||||
path: docker-stub-EU-sha256.txt
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Build Public API Swagger
|
||||
- name: Build Swagger files
|
||||
run: |
|
||||
cd ./src/Api
|
||||
echo "Restore tools"
|
||||
dotnet tool restore
|
||||
echo "Publish"
|
||||
dotnet publish -c "Release" -o obj/build-output/publish
|
||||
|
||||
dotnet swagger tofile --output ../../swagger.json --host https://api.bitwarden.com \
|
||||
./obj/build-output/publish/Api.dll public
|
||||
cd ../..
|
||||
env:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
swaggerGen: "True"
|
||||
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
|
||||
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
|
||||
cd ./dev
|
||||
pwsh ./generate_openapi_files.ps1
|
||||
|
||||
- name: Upload Public API Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: swagger.json
|
||||
path: swagger.json
|
||||
path: api.public.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@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: internal.json
|
||||
path: internal.json
|
||||
path: api.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Identity Swagger artifact
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: identity.json
|
||||
path: identity.json
|
||||
if-no-files-found: error
|
||||
if-no-files-found: error
|
||||
|
||||
build-mssqlmigratorutility:
|
||||
name: Build MSSQL migrator utility
|
||||
runs-on: ubuntu-22.04
|
||||
needs: lint
|
||||
needs:
|
||||
- lint
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -467,10 +425,13 @@ jobs:
|
||||
- win-x64
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -486,7 +447,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact for Windows
|
||||
if: ${{ contains(matrix.target, 'win') == true }}
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility.exe
|
||||
@@ -494,7 +455,7 @@ jobs:
|
||||
|
||||
- name: Upload project artifact
|
||||
if: ${{ contains(matrix.target, 'win') == false }}
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: MsSqlMigratorUtility-${{ matrix.target }}
|
||||
path: util/MsSqlMigratorUtility/obj/build-output/publish/MsSqlMigratorUtility
|
||||
@@ -502,13 +463,21 @@ jobs:
|
||||
|
||||
self-host-build:
|
||||
name: Trigger self-host build
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
&& (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc')
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
@@ -517,31 +486,39 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Trigger self-host build
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger Bitwarden Lite build
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: 'bitwarden',
|
||||
repo: 'self-host',
|
||||
workflow_id: 'build-unified.yml',
|
||||
workflow_id: 'build-bitwarden-lite.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' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-22.04
|
||||
needs: build-docker
|
||||
needs:
|
||||
- build-artifacts
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure - CI subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve GitHub PAT secrets
|
||||
id: retrieve-secret-pat
|
||||
@@ -550,8 +527,11 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Trigger k8s deploy
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ steps.retrieve-secret-pat.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
script: |
|
||||
@@ -566,6 +546,23 @@ jobs:
|
||||
}
|
||||
})
|
||||
|
||||
setup-ephemeral-environment:
|
||||
name: Setup Ephemeral Environment
|
||||
needs:
|
||||
- build-artifacts
|
||||
if: |
|
||||
needs.build-artifacts.outputs.has_secrets == 'true'
|
||||
&& github.event_name == 'pull_request'
|
||||
&& contains(github.event.pull_request.labels.*.name, 'ephemeral-environment')
|
||||
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||
with:
|
||||
project: server
|
||||
pull_request_number: ${{ github.event.number || 0 }}
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
check-failures:
|
||||
name: Check for failures
|
||||
if: always()
|
||||
@@ -573,25 +570,26 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- build-artifacts
|
||||
- build-docker
|
||||
- upload
|
||||
- build-mssqlmigratorutility
|
||||
- self-host-build
|
||||
- trigger-k8s-deploy
|
||||
permissions:
|
||||
id-token: write
|
||||
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'
|
||||
&& (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@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
if: failure()
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve secrets
|
||||
id: retrieve-secrets
|
||||
@@ -601,6 +599,9 @@ jobs:
|
||||
keyvault: "bitwarden-ci"
|
||||
secrets: "devops-alerts-slack-webhook-url"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Notify Slack on failure
|
||||
uses: act10ns/slack@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
|
||||
31
.github/workflows/build_target.yml
vendored
Normal file
31
.github/workflows/build_target.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: Build on PR Target
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
run-workflow:
|
||||
name: Run Build on PR Target
|
||||
needs: check-run
|
||||
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
|
||||
uses: ./.github/workflows/build.yml
|
||||
secrets: inherit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
id-token: write
|
||||
security-events: write
|
||||
26
.github/workflows/cleanup-after-pr.yml
vendored
26
.github/workflows/cleanup-after-pr.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Container registry cleanup
|
||||
|
||||
on:
|
||||
@@ -12,14 +11,18 @@ jobs:
|
||||
build-docker:
|
||||
name: Remove branch-specific Docker images
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Log in to Azure - production subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n $_AZ_REGISTRY --only-show-errors
|
||||
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
|
||||
|
||||
########## Remove Docker images ##########
|
||||
- name: Remove the Docker image from ACR
|
||||
@@ -42,20 +45,20 @@ jobs:
|
||||
- Setup
|
||||
- Sso
|
||||
run: |
|
||||
for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - )
|
||||
for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
|
||||
do
|
||||
SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}')
|
||||
SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}')
|
||||
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
|
||||
|
||||
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
|
||||
TAG_EXISTS=$(
|
||||
az acr repository show-tags --name $_AZ_REGISTRY --repository $SERVICE_NAME \
|
||||
| jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")'
|
||||
az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
|
||||
| jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
|
||||
)
|
||||
|
||||
if [[ "$TAG_EXISTS" == "true" ]]; then
|
||||
echo "[*] Tag exists. Removing tag"
|
||||
az acr repository delete --name $_AZ_REGISTRY --image $SERVICE_NAME:$IMAGE_TAG --yes
|
||||
az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes
|
||||
else
|
||||
echo "[*] Tag does not exist. No action needed"
|
||||
fi
|
||||
@@ -63,3 +66,6 @@ jobs:
|
||||
|
||||
- name: Log out of Docker
|
||||
run: docker logout
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
31
.github/workflows/cleanup-rc-branch.yml
vendored
31
.github/workflows/cleanup-rc-branch.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Cleanup RC Branch
|
||||
|
||||
on:
|
||||
@@ -10,11 +9,16 @@ jobs:
|
||||
delete-rc:
|
||||
name: Delete RC Branch
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Login to Azure - CI Subscription
|
||||
uses: Azure/login@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Retrieve bot secrets
|
||||
id: retrieve-bot-secrets
|
||||
@@ -23,11 +27,16 @@ jobs:
|
||||
keyvault: bitwarden-ci
|
||||
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if a RC branch exists
|
||||
id: branch-check
|
||||
@@ -36,11 +45,11 @@ jobs:
|
||||
rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
|
||||
|
||||
if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then
|
||||
echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY
|
||||
echo "name=hotfix-rc" >> $GITHUB_OUTPUT
|
||||
echo "hotfix-rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "name=hotfix-rc" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${rc_branch_check}" -gt 0 ]]; then
|
||||
echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY
|
||||
echo "name=rc" >> $GITHUB_OUTPUT
|
||||
echo "rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
echo "name=rc" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Delete RC branch
|
||||
@@ -48,6 +57,6 @@ jobs:
|
||||
BRANCH_NAME: ${{ steps.branch-check.outputs.name }}
|
||||
run: |
|
||||
if ! [[ -z "$BRANCH_NAME" ]]; then
|
||||
git push --quiet origin --delete $BRANCH_NAME
|
||||
echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY
|
||||
git push --quiet origin --delete "$BRANCH_NAME"
|
||||
echo "Deleted $BRANCH_NAME branch." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
61
.github/workflows/code-references.yml
vendored
61
.github/workflows/code-references.yml
vendored
@@ -1,59 +1,80 @@
|
||||
name: Collect code references
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-ld-secret:
|
||||
name: Check for LD secret
|
||||
check-secret-access:
|
||||
name: Check for secret access
|
||||
runs-on: ubuntu-22.04
|
||||
outputs:
|
||||
available: ${{ steps.check-ld-secret.outputs.available }}
|
||||
permissions:
|
||||
contents: read
|
||||
available: ${{ steps.check-secret-access.outputs.available }}
|
||||
permissions: {}
|
||||
|
||||
steps:
|
||||
- name: Check
|
||||
id: check-ld-secret
|
||||
id: check-secret-access
|
||||
run: |
|
||||
if [ "${{ secrets.LD_ACCESS_TOKEN }}" != '' ]; then
|
||||
echo "available=true" >> $GITHUB_OUTPUT;
|
||||
if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
|
||||
echo "available=true" >> "$GITHUB_OUTPUT";
|
||||
else
|
||||
echo "available=false" >> $GITHUB_OUTPUT;
|
||||
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' }}
|
||||
needs: check-secret-access
|
||||
if: ${{ needs.check-secret-access.outputs.available == 'true' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-server
|
||||
secrets: "LD-ACCESS-TOKEN"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Collect
|
||||
id: collect
|
||||
uses: launchdarkly/find-code-references-in-pull-request@d008aa4f321d8cd35314d9cb095388dcfde84439 # v2.0.0
|
||||
uses: launchdarkly/find-code-references@e3e9da201b87ada54eb4c550c14fb783385c5c8a # v2.13.0
|
||||
with:
|
||||
project-key: default
|
||||
environment-key: dev
|
||||
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
accessToken: ${{ steps.get-kv-secrets.outputs.LD-ACCESS-TOKEN }}
|
||||
projKey: default
|
||||
allowTags: true
|
||||
|
||||
- name: Add label
|
||||
if: steps.collect.outputs.any-changed == 'true'
|
||||
run: gh pr edit $PR_NUMBER --add-label feature-flag
|
||||
run: gh pr edit "$PR_NUMBER" --add-label feature-flag
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
- name: Remove label
|
||||
if: steps.collect.outputs.any-changed == 'false'
|
||||
run: gh pr edit $PR_NUMBER --remove-label feature-flag
|
||||
run: gh pr edit "$PR_NUMBER" --remove-label feature-flag
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
102
.github/workflows/container-registry-purge.yml
vendored
102
.github/workflows/container-registry-purge.yml
vendored
@@ -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@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
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@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
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@44541246747a30eb3102d87f7a4cc5471b0ffb7d # v2.1.0
|
||||
if: failure()
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
|
||||
with:
|
||||
status: ${{ job.status }}
|
||||
10
.github/workflows/enforce-labels.yml
vendored
10
.github/workflows/enforce-labels.yml
vendored
@@ -1,19 +1,21 @@
|
||||
---
|
||||
name: Enforce PR labels
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
pull_request:
|
||||
types: [labeled, unlabeled, opened, reopened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
|
||||
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
|
||||
|
||||
20
.github/workflows/ephemeral-environment.yml
vendored
Normal file
20
.github/workflows/ephemeral-environment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Ephemeral Environment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
setup-ephemeral-environment:
|
||||
name: Setup Ephemeral Environment
|
||||
if: github.event.label.name == 'ephemeral-environment'
|
||||
uses: bitwarden/gh-actions/.github/workflows/_ephemeral_environment_manager.yml@main
|
||||
with:
|
||||
project: server
|
||||
pull_request_number: ${{ github.event.number }}
|
||||
sync_environment: false
|
||||
secrets: inherit
|
||||
114
.github/workflows/load-test.yml
vendored
Normal file
114
.github/workflows/load-test.yml
vendored
Normal file
@@ -0,0 +1,114 @@
|
||||
name: Load test
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1" # Run every Monday at 00:00
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
test-id:
|
||||
type: string
|
||||
description: "Identifier label for Datadog metrics"
|
||||
default: "server-load-test"
|
||||
k6-test-path:
|
||||
type: string
|
||||
description: "Path to load test files"
|
||||
default: "perf/load/*.js"
|
||||
k6-flags:
|
||||
type: string
|
||||
description: "Additional k6 flags"
|
||||
api-env-url:
|
||||
type: string
|
||||
description: "URL of the API environment"
|
||||
default: "https://api.qa.bitwarden.pw"
|
||||
identity-env-url:
|
||||
type: string
|
||||
description: "URL of the Identity environment"
|
||||
default: "https://identity.qa.bitwarden.pw"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Secret configuration
|
||||
AZURE_KEY_VAULT_NAME: gh-server
|
||||
AZURE_KEY_VAULT_SECRETS: DD-API-KEY, K6-CLIENT-ID, K6-AUTH-USER-EMAIL, K6-AUTH-USER-PASSWORD-HASH
|
||||
# Specify defaults for scheduled runs
|
||||
TEST_ID: ${{ inputs.test-id || 'server-load-test' }}
|
||||
K6_TEST_PATH: ${{ inputs.k6-test-path || 'perf/load/*.js' }}
|
||||
API_ENV_URL: ${{ inputs.api-env-url || 'https://api.qa.bitwarden.pw' }}
|
||||
IDENTITY_ENV_URL: ${{ inputs.identity-env-url || 'https://identity.qa.bitwarden.pw' }}
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
name: Run load tests
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: ${{ env.AZURE_KEY_VAULT_NAME }}
|
||||
secrets: ${{ env.AZURE_KEY_VAULT_SECRETS }}
|
||||
|
||||
- name: Log out of Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
# Datadog agent for collecting OTEL metrics from k6
|
||||
- name: Start Datadog agent
|
||||
env:
|
||||
DD_API_KEY: ${{ steps.get-kv-secrets.outputs.DD-API-KEY }}
|
||||
run: |
|
||||
docker run --detach \
|
||||
--name datadog-agent \
|
||||
-p 4317:4317 \
|
||||
-p 5555:5555 \
|
||||
-e DD_SITE=us3.datadoghq.com \
|
||||
-e DD_API_KEY="${DD_API_KEY}" \
|
||||
-e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \
|
||||
-e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \
|
||||
-e DD_HEALTH_PORT=5555 \
|
||||
-e HOST_PROC=/proc \
|
||||
--volume /var/run/docker.sock:/var/run/docker.sock:ro \
|
||||
--volume /sys/fs/cgroup/:/host/sys/fs/cgroup:ro \
|
||||
--health-cmd "curl -f http://localhost:5555/health || exit 1" \
|
||||
--health-interval 10s \
|
||||
--health-timeout 5s \
|
||||
--health-retries 10 \
|
||||
--health-start-period 30s \
|
||||
--pid host \
|
||||
datadog/agent:7-full@sha256:7ea933dec3b8baa8c19683b1c3f6f801dbf3291f748d9ed59234accdaac4e479
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up k6
|
||||
uses: grafana/setup-k6-action@ffe7d7290dfa715e48c2ccc924d068444c94bde2 # v1.1.0
|
||||
|
||||
- name: Run k6 tests
|
||||
uses: grafana/run-k6-action@c6b79182b9b666aa4f630f4a6be9158ead62536e # v1.2.0
|
||||
continue-on-error: false
|
||||
env:
|
||||
K6_OTEL_METRIC_PREFIX: k6_
|
||||
K6_OTEL_GRPC_EXPORTER_INSECURE: true
|
||||
# Load test specific environment variables
|
||||
API_URL: ${{ env.API_ENV_URL }}
|
||||
IDENTITY_URL: ${{ env.IDENTITY_ENV_URL }}
|
||||
CLIENT_ID: ${{ steps.get-kv-secrets.outputs.K6-CLIENT-ID }}
|
||||
AUTH_USER_EMAIL: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-EMAIL }}
|
||||
AUTH_USER_PASSWORD_HASH: ${{ steps.get-kv-secrets.outputs.K6-AUTH-USER-PASSWORD-HASH }}
|
||||
with:
|
||||
flags: >-
|
||||
--tag test-id=${{ env.TEST_ID }}
|
||||
-o experimental-opentelemetry
|
||||
${{ inputs.k6-flags }}
|
||||
path: ${{ env.K6_TEST_PATH }}
|
||||
11
.github/workflows/protect-files.yml
vendored
11
.github/workflows/protect-files.yml
vendored
@@ -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:
|
||||
@@ -17,6 +16,9 @@ jobs:
|
||||
changed-files:
|
||||
name: Check for file changes
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
changes: ${{steps.check-changes.outputs.changes_detected}}
|
||||
|
||||
@@ -29,9 +31,10 @@ jobs:
|
||||
label: "DB-migrations-changed"
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check-changes
|
||||
@@ -41,9 +44,9 @@ jobs:
|
||||
for file in $MODIFIED_FILES
|
||||
do
|
||||
if [[ $file == *"${{ matrix.path }}"* ]]; then
|
||||
echo "changes_detected=true" >> $GITHUB_OUTPUT
|
||||
echo "changes_detected=true" >> "$GITHUB_OUTPUT"
|
||||
break
|
||||
else echo "changes_detected=false" >> $GITHUB_OUTPUT
|
||||
else echo "changes_detected=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
199
.github/workflows/publish.yml
vendored
Normal file
199
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,199 @@
|
||||
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
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
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
|
||||
env:
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ "${INPUT_VERSION}" == "latest" || "${INPUT_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: ${INPUT_VERSION}"
|
||||
echo "version=${INPUT_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
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
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
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- 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
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Log in to Azure ACR
|
||||
run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
|
||||
|
||||
- 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
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
update-deployment:
|
||||
name: Update Deployment Status
|
||||
runs-on: ubuntu-22.04
|
||||
needs:
|
||||
- setup
|
||||
- publish-docker
|
||||
permissions:
|
||||
deployments: write
|
||||
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 }}
|
||||
116
.github/workflows/release.yml
vendored
116
.github/workflows/release.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Release
|
||||
run-name: Release ${{ inputs.release_type }}
|
||||
|
||||
@@ -18,6 +17,9 @@ on:
|
||||
env:
|
||||
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Setup
|
||||
@@ -37,7 +39,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check release version
|
||||
id: version
|
||||
@@ -50,106 +55,15 @@ jobs:
|
||||
- name: Get branch name
|
||||
id: branch
|
||||
run: |
|
||||
BRANCH_NAME=$(basename ${{ github.ref }})
|
||||
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
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: ${{ 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- 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.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 [[ "${{ 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 [[ "${{ 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
|
||||
BRANCH_NAME=$(basename "${GITHUB_REF}")
|
||||
echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
|
||||
|
||||
release:
|
||||
name: Create GitHub release
|
||||
runs-on: ubuntu-22.04
|
||||
needs: setup
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download latest release Docker stubs
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
@@ -159,9 +73,7 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: ${{ needs.setup.outputs.branch-name }}
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Dry Run - Download latest release Docker stubs
|
||||
@@ -172,19 +84,15 @@ jobs:
|
||||
workflow_conclusion: success
|
||||
branch: main
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
|
||||
- name: Create release
|
||||
if: ${{ inputs.release_type != 'Dry Run' }}
|
||||
uses: ncipollo/release-action@2c591bcc8ecdcd2db72b97d6147f871fcd833ba5 # v1.14.0
|
||||
uses: ncipollo/release-action@440c8c1cb0ed28b9f43e4d1d670870f059653174 # v1.16.0
|
||||
with:
|
||||
artifacts: "docker-stub-US.zip,
|
||||
docker-stub-US-sha256.txt,
|
||||
docker-stub-EU.zip,
|
||||
docker-stub-EU-sha256.txt,
|
||||
swagger.json"
|
||||
commit: ${{ github.sha }}
|
||||
tag: "v${{ needs.setup.outputs.release_version }}"
|
||||
|
||||
249
.github/workflows/repository-management.yml
vendored
Normal file
249
.github/workflows/repository-management.yml
vendored
Normal file
@@ -0,0 +1,249 @@
|
||||
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
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
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"
|
||||
|
||||
bump_version:
|
||||
name: Bump Version
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- setup
|
||||
outputs:
|
||||
version: ${{ steps.set-final-version-output.outputs.version }}
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- 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@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
|
||||
- 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.
|
||||
if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; 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 }}
|
||||
BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }}
|
||||
BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }}
|
||||
CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }}
|
||||
run: |
|
||||
if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then
|
||||
echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit files
|
||||
env:
|
||||
FINAL_VERSION: ${{ steps.set-final-version-output.outputs.version }}
|
||||
run: git commit -m "Bumped version to $FINAL_VERSION" -a
|
||||
|
||||
- name: Push changes
|
||||
run: git push
|
||||
|
||||
cut_branch:
|
||||
name: Cut branch
|
||||
if: ${{ needs.setup.outputs.branch != 'none' }}
|
||||
needs:
|
||||
- setup
|
||||
- bump_version
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Log in to Azure
|
||||
uses: bitwarden/gh-actions/azure-login@main
|
||||
with:
|
||||
subscription_id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
tenant_id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
client_id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
- name: Get Azure Key Vault secrets
|
||||
id: get-kv-secrets
|
||||
uses: bitwarden/gh-actions/get-keyvault-secrets@main
|
||||
with:
|
||||
keyvault: gh-org-bitwarden
|
||||
secrets: "BW-GHAPP-ID,BW-GHAPP-KEY"
|
||||
|
||||
- name: Log out from Azure
|
||||
uses: bitwarden/gh-actions/azure-logout@main
|
||||
|
||||
- name: Generate GH App token
|
||||
uses: actions/create-github-app-token@a8d616148505b5069dccd32f177bb87d7f39123b # v2.1.1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-ID }}
|
||||
private-key: ${{ steps.get-kv-secrets.outputs.BW-GHAPP-KEY }}
|
||||
|
||||
- name: Check out target ref
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ inputs.target_ref }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
fetch-depth: 0
|
||||
|
||||
- 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"
|
||||
|
||||
move_edd_db_scripts:
|
||||
name: Move EDD database scripts
|
||||
needs: cut_branch
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
uses: ./.github/workflows/_move_edd_db_scripts.yml
|
||||
secrets: inherit
|
||||
28
.github/workflows/respond.yml
vendored
Normal file
28
.github/workflows/respond.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Respond
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
respond:
|
||||
name: Respond
|
||||
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
21
.github/workflows/review-code.yml
vendored
Normal file
21
.github/workflows/review-code.yml
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
name: Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
review:
|
||||
name: Review
|
||||
uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
90
.github/workflows/scan.yml
vendored
90
.github/workflows/scan.yml
vendored
@@ -7,85 +7,49 @@ on:
|
||||
- "main"
|
||||
- "rc"
|
||||
- "hotfix-rc"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches-ignore:
|
||||
- main
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
types: [opened, synchronize, reopened]
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-run:
|
||||
name: Check PR run
|
||||
uses: bitwarden/gh-actions/.github/workflows/check-run.yml@main
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
sast:
|
||||
name: SAST scan
|
||||
runs-on: ubuntu-22.04
|
||||
name: Checkmarx
|
||||
uses: bitwarden/gh-actions/.github/workflows/_checkmarx.yml@main
|
||||
needs: check-run
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Scan with Checkmarx
|
||||
uses: checkmarx/ast-github-action@1fe318de2993222574e6249750ba9000a4e2a6cd # 2.0.33
|
||||
env:
|
||||
INCREMENTAL: "${{ contains(github.event_name, 'pull_request') && '--sast-incremental' || '' }}"
|
||||
with:
|
||||
project_name: ${{ github.repository }}
|
||||
cx_tenant: ${{ secrets.CHECKMARX_TENANT }}
|
||||
base_uri: https://ast.checkmarx.net/
|
||||
cx_client_id: ${{ secrets.CHECKMARX_CLIENT_ID }}
|
||||
cx_client_secret: ${{ secrets.CHECKMARX_SECRET }}
|
||||
additional_params: |
|
||||
--report-format sarif \
|
||||
--filter "state=TO_VERIFY;PROPOSED_NOT_EXPLOITABLE;CONFIRMED;URGENT" \
|
||||
--output-path . ${{ env.INCREMENTAL }}
|
||||
|
||||
- name: Upload Checkmarx results to GitHub
|
||||
uses: github/codeql-action/upload-sarif@2c779ab0d087cd7fe7b826087247c2c81f27bfa6 # v3.26.5
|
||||
with:
|
||||
sarif_file: cx_result.sarif
|
||||
id-token: write
|
||||
|
||||
quality:
|
||||
name: Quality scan
|
||||
runs-on: ubuntu-22.04
|
||||
name: Sonar
|
||||
uses: bitwarden/gh-actions/.github/workflows/_sonar.yml@main
|
||||
needs: check-run
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@6a0805fcefea3d4657a47ac4c165951e33482018 # v4.2.2
|
||||
with:
|
||||
java-version: 17
|
||||
distribution: "zulu"
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
|
||||
- name: Install SonarCloud scanner
|
||||
run: dotnet tool install dotnet-sonarscanner -g
|
||||
|
||||
- name: Scan with SonarCloud
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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"
|
||||
dotnet build
|
||||
dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
|
||||
id-token: write
|
||||
with:
|
||||
sonar-config: "dotnet"
|
||||
|
||||
8
.github/workflows/stale-bot.yml
vendored
8
.github/workflows/stale-bot.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Staleness
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -9,9 +8,14 @@ jobs:
|
||||
stale:
|
||||
name: Check for stale issues and PRs
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check
|
||||
uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0
|
||||
uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
with:
|
||||
stale-issue-label: "needs-reply"
|
||||
stale-pr-label: "needs-changes"
|
||||
|
||||
91
.github/workflows/test-database.yml
vendored
91
.github/workflows/test-database.yml
vendored
@@ -1,4 +1,3 @@
|
||||
---
|
||||
name: Database testing
|
||||
|
||||
on:
|
||||
@@ -18,6 +17,7 @@ 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/test-database.yml" # This file
|
||||
@@ -29,17 +29,27 @@ 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
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-22.04
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Restore tools
|
||||
run: dotnet tool restore
|
||||
@@ -52,10 +62,15 @@ jobs:
|
||||
docker compose --profile mssql --profile postgres --profile mysql up -d
|
||||
shell: pwsh
|
||||
|
||||
- name: Add MariaDB for Bitwarden Lite
|
||||
# Use a different port than MySQL
|
||||
run: |
|
||||
docker run --detach --name mariadb --env MARIADB_ROOT_PASSWORD=mariadb-password -p 4306:3306 mariadb:10
|
||||
|
||||
# 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"'
|
||||
@@ -85,6 +100,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"'
|
||||
@@ -112,18 +133,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"
|
||||
# Bitwarden Lite MariaDB
|
||||
BW_TEST_DATABASES__4__TYPE: "MySql"
|
||||
BW_TEST_DATABASES__4__CONNECTIONSTRING: "server=localhost;port=4306;uid=root;pwd=mariadb-password;database=vault_dev;Allow User Variables=true"
|
||||
run: dotnet test --logger "trx;LogFileName=infrastructure-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
|
||||
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@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: always()
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
path: "**/*-test-results.trx"
|
||||
path: "./**/*-test-results.trx"
|
||||
reporter: dotnet-trx
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
|
||||
- name: Docker Compose down
|
||||
if: always()
|
||||
working-directory: "dev"
|
||||
@@ -135,10 +178,12 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -152,7 +197,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload DACPAC
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: sql.dacpac
|
||||
path: Sql.dacpac
|
||||
@@ -178,7 +223,7 @@ jobs:
|
||||
shell: pwsh
|
||||
|
||||
- name: Report validation results
|
||||
uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: report.xml
|
||||
path: |
|
||||
@@ -188,11 +233,27 @@ jobs:
|
||||
- name: Validate XML
|
||||
run: |
|
||||
if grep -q "<Operations>" "report.xml"; then
|
||||
echo
|
||||
echo "Migrations are out of sync with sqlproj!"
|
||||
echo "ERROR: Migration files are not in sync with the SQL project"
|
||||
echo ""
|
||||
echo "Check these locations:"
|
||||
echo " - Migration scripts: util/Migrator/DbScripts/"
|
||||
echo " - SQL project files: src/Sql/"
|
||||
echo " - Download 'report.xml' artifact for full details"
|
||||
echo ""
|
||||
|
||||
# Show actual SQL differences - exclude database setup commands
|
||||
if [ -s "diff.sql" ]; then
|
||||
echo "Key SQL differences:"
|
||||
# Show meaningful schema differences, filtering out database setup noise
|
||||
grep -E "^(CREATE|DROP|ALTER)" diff.sql | grep -v "ALTER DATABASE" | grep -v "DatabaseName" | head -5
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Common causes: naming differences (underscores, case), missing objects, or definition mismatches"
|
||||
|
||||
exit 1
|
||||
else
|
||||
echo "Report looks good"
|
||||
echo "SUCCESS: Database validation passed"
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
|
||||
42
.github/workflows/test.yml
vendored
42
.github/workflows/test.yml
vendored
@@ -13,29 +13,10 @@ 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
|
||||
@@ -46,10 +27,20 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@6bd8b7f7774af54e05809fcc5431931b3eb1ddee # v4.0.1
|
||||
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
|
||||
|
||||
- name: Install rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: Swatinem/rust-cache@f0deed1e0edfc6a9be95417288c0e1099b1eeec3 # v2.7.7
|
||||
|
||||
- name: Print environment
|
||||
run: |
|
||||
@@ -68,8 +59,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@31a54ee7ebcacc03a09ea97a7e5465a47b84aea5 # v1.9.1
|
||||
if: ${{ needs.check-test-secrets.outputs.available == 'true' && !cancelled() }}
|
||||
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
|
||||
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
|
||||
with:
|
||||
name: Test Results
|
||||
path: "**/*-test-results.trx"
|
||||
@@ -77,7 +68,4 @@ jobs:
|
||||
fail-on-error: true
|
||||
|
||||
- name: Upload to codecov.io
|
||||
uses: codecov/codecov-action@e28ff129e5465c2c0dcc6f003fc735cb6ae0c673 # v4.5.0
|
||||
if: ${{ needs.check-test-secrets.outputs.available == 'true' }}
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
|
||||
|
||||
259
.github/workflows/version-bump.yml
vendored
259
.github/workflows/version-bump.yml
vendored
@@ -1,259 +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
|
||||
enable_slack_notification:
|
||||
description: "Enable Slack notifications for upcoming release?"
|
||||
default: false
|
||||
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: Slack Notification Check
|
||||
run: |
|
||||
if [[ "${{ inputs.enable_slack_notification }}" == true ]]; then
|
||||
echo "Slack notifications enabled."
|
||||
else
|
||||
echo "Slack notifications disabled."
|
||||
fi
|
||||
|
||||
- name: Check out branch
|
||||
uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
|
||||
- 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@e15b166166a8746d1a47596803bd8c1b595455cf # v1.6.0
|
||||
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@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6.1.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' && inputs.enable_slack_notification == 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@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7
|
||||
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
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -129,7 +129,7 @@ publish/
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# TODO: Comment the next line if you want to checkin your web deploy settings
|
||||
# TODO: Comment the next line if you want to checkin your web deploy settings
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
@@ -214,6 +214,10 @@ bitwarden_license/src/Sso/wwwroot/assets
|
||||
.idea/*
|
||||
**/**.swp
|
||||
.mono
|
||||
src/Core/MailTemplates/Mjml/out
|
||||
src/Core/MailTemplates/Mjml/out-hbs
|
||||
NativeMethods.g.cs
|
||||
util/RustSdk/rust/target
|
||||
|
||||
src/Admin/Admin.zip
|
||||
src/Api/Api.zip
|
||||
@@ -225,3 +229,11 @@ src/Notifications/Notifications.zip
|
||||
bitwarden_license/src/Portal/Portal.zip
|
||||
bitwarden_license/src/Sso/Sso.zip
|
||||
**/src/**/flags.json
|
||||
|
||||
# Generated swagger specs
|
||||
/identity.json
|
||||
/api.json
|
||||
/api.public.json
|
||||
|
||||
# Serena
|
||||
.serena/
|
||||
|
||||
18
.vscode/extensions.json
vendored
Normal file
18
.vscode/extensions.json
vendored
Normal 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
17
.vscode/launch.json
vendored
@@ -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
|
||||
|
||||
13
.vscode/tasks.json
vendored
13
.vscode/tasks.json
vendored
@@ -26,6 +26,19 @@
|
||||
"$msCompile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "buildIdentityApiSso",
|
||||
"hide": true,
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"buildIdentity",
|
||||
"buildAPI",
|
||||
"buildSso"
|
||||
],
|
||||
"problemMatcher": [
|
||||
"$msCompile"
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "buildFullServer",
|
||||
"hide": true,
|
||||
|
||||
@@ -3,65 +3,31 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
|
||||
<Version>2024.8.1</Version>
|
||||
<Version>2025.11.1</Version>
|
||||
|
||||
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<IsTestProject Condition="'$(IsTestProject)' == '' and ($(MSBuildProjectName.EndsWith('.Test')) or $(MSBuildProjectName.EndsWith('.IntegrationTest')))">true</IsTestProject>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' == 'true'">annotations</Nullable>
|
||||
<Nullable Condition="'$(Nullable)' == '' and '$(IsTestProject)' != 'true'">enable</Nullable>
|
||||
<TreatWarningsAsErrors Condition="'$(TreatWarningsAsErrors)' == ''">true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
This section is for packages that we use multiple times throughout the solution
|
||||
It gives us a single place to manage the version to ensure we are using the same version
|
||||
across the solution.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
|
||||
-->
|
||||
|
||||
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/xunit
|
||||
-->
|
||||
|
||||
<XUnitVersion>2.6.6</XUnitVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
|
||||
-->
|
||||
|
||||
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/coverlet.collector
|
||||
-->
|
||||
|
||||
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/NSubstitute
|
||||
-->
|
||||
|
||||
<NSubstituteVersion>5.1.0</NSubstituteVersion>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
|
||||
-->
|
||||
|
||||
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
|
||||
<!--
|
||||
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
|
||||
-->
|
||||
|
||||
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
This section is for getting & setting the gitHash value, which can easily be accessed
|
||||
via the Core.Utilities.AssemblyHelpers class.
|
||||
-->
|
||||
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
|
||||
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
|
||||
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
|
||||
</Exec>
|
||||
</Target>
|
||||
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
|
||||
<_Parameter1>GitHash</_Parameter1>
|
||||
<_Parameter2>$(SourceRevisionId)</_Parameter2>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
@@ -5,9 +5,6 @@
|
||||
<a href="https://github.com/bitwarden/server/actions/workflows/build.yml?query=branch:main" target="_blank">
|
||||
<img src="https://github.com/bitwarden/server/actions/workflows/build.yml/badge.svg?branch=main" alt="Github Workflow build on main" />
|
||||
</a>
|
||||
<a href="https://hub.docker.com/u/bitwarden/" target="_blank">
|
||||
<img src="https://img.shields.io/docker/pulls/bitwarden/api.svg" alt="DockerHub" />
|
||||
</a>
|
||||
<a href="https://gitter.im/bitwarden/Lobby" target="_blank">
|
||||
<img src="https://badges.gitter.im/bitwarden/Lobby.svg" alt="gitter chat" />
|
||||
</a>
|
||||
@@ -26,12 +23,12 @@ Please refer to the [Server Setup Guide](https://contributing.bitwarden.com/gett
|
||||
## Deploy
|
||||
|
||||
<p align="center">
|
||||
<a href="https://hub.docker.com/u/bitwarden/" target="_blank">
|
||||
<a href="https://github.com/orgs/bitwarden/packages" target="_blank">
|
||||
<img src="https://i.imgur.com/SZc8JnH.png" alt="docker" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [Docker Hub](https://hub.docker.com/u/bitwarden/).
|
||||
You can deploy Bitwarden using Docker containers on Windows, macOS, and Linux distributions. Use the provided PowerShell and Bash scripts to get started quickly. Find all of the Bitwarden images on [GitHub Container Registry](https://github.com/orgs/bitwarden/packages).
|
||||
|
||||
Full documentation for deploying Bitwarden with Docker can be found in our help center at: https://help.bitwarden.com/article/install-on-premise/
|
||||
|
||||
|
||||
@@ -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,21 @@ 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
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Core.IntegrationTest", "test\Core.IntegrationTest\Core.IntegrationTest.csproj", "{3631BA42-6731-4118-A917-DAA43C5032B9}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Seeder", "util\Seeder\Seeder.csproj", "{9A612EBA-1C0E-42B8-982B-62F0EE81000A}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DbSeederUtility", "util\DbSeederUtility\DbSeederUtility.csproj", "{17A89266-260A-4A03-81AE-C0468C6EE06E}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RustSdk", "util\RustSdk\RustSdk.csproj", "{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharedWeb.Test", "test\SharedWeb.Test\SharedWeb.Test.csproj", "{AD59537D-5259-4B7A-948F-0CF58E80B359}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -308,6 +322,38 @@ 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
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -357,6 +403,14 @@ 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}
|
||||
{3631BA42-6731-4118-A917-DAA43C5032B9} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{9A612EBA-1C0E-42B8-982B-62F0EE81000A} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{17A89266-260A-4A03-81AE-C0468C6EE06E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{D1513D90-E4F5-44A9-9121-5E46E3E4A3F7} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
|
||||
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
|
||||
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}
|
||||
|
||||
@@ -1,16 +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.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
|
||||
@@ -21,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 CreateBusinessUnitAsync(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)
|
||||
@@ -47,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);
|
||||
|
||||
@@ -64,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)
|
||||
@@ -95,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,
|
||||
@@ -105,5 +99,6 @@ public class CreateProviderCommand : ICreateProviderCommand
|
||||
PurchasedSeats = 0,
|
||||
AllocatedSeats = 0
|
||||
};
|
||||
await _providerPlanRepository.CreateAsync(plan);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
using Bit.Core;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
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.Constants;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
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 Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.AdminConsole.Providers;
|
||||
@@ -21,33 +24,36 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
private readonly IEventService _eventService;
|
||||
private readonly IMailService _mailService;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
|
||||
private readonly IStripeAdapter _stripeAdapter;
|
||||
private readonly IFeatureService _featureService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly ISubscriberService _subscriberService;
|
||||
private readonly IHasConfirmedOwnersExceptQuery _hasConfirmedOwnersExceptQuery;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public RemoveOrganizationFromProviderCommand(
|
||||
IEventService eventService,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationService organizationService,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
IFeatureService featureService,
|
||||
IProviderBillingService providerBillingService,
|
||||
ISubscriberService subscriberService)
|
||||
ISubscriberService subscriberService,
|
||||
IHasConfirmedOwnersExceptQuery hasConfirmedOwnersExceptQuery,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_mailService = mailService;
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationService = organizationService;
|
||||
_providerOrganizationRepository = providerOrganizationRepository;
|
||||
_stripeAdapter = stripeAdapter;
|
||||
_featureService = featureService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_subscriberService = subscriberService;
|
||||
_hasConfirmedOwnersExceptQuery = hasConfirmedOwnersExceptQuery;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
public async Task RemoveOrganizationFromProvider(
|
||||
@@ -63,9 +69,9 @@ 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))
|
||||
{
|
||||
throw new BadRequestException("Organization must have at least one confirmed owner.");
|
||||
@@ -90,7 +96,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
/// <summary>
|
||||
/// When a client organization is unlinked from a provider, we have to check if they're Stripe-enabled
|
||||
/// and, if they are, we remove their MSP discount and set their Subscription to `send_invoice`. This is because
|
||||
/// the provider's payment method will be removed from their Stripe customer causing ensuing charges to fail. Lastly,
|
||||
/// the provider's payment method will be removed from their Stripe customer, causing ensuing charges to fail. Lastly,
|
||||
/// we email the organization owners letting them know they need to add a new payment method.
|
||||
/// </summary>
|
||||
private async Task ResetOrganizationBillingAsync(
|
||||
@@ -98,61 +104,74 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
Provider provider,
|
||||
IEnumerable<string> organizationOwnerEmails)
|
||||
{
|
||||
var isConsolidatedBillingEnabled = _featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling);
|
||||
|
||||
if (isConsolidatedBillingEnabled &&
|
||||
provider.Status == ProviderStatusType.Billable &&
|
||||
organization.Status == OrganizationStatusType.Managed &&
|
||||
!string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
if (provider.IsBillable() &&
|
||||
organization.IsValidClient())
|
||||
{
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
// An organization converted to a business unit will not have a Customer since it was given to the business unit.
|
||||
if (string.IsNullOrEmpty(organization.GatewayCustomerId))
|
||||
{
|
||||
await _providerBillingService.CreateCustomerForClientOrganization(provider, organization);
|
||||
}
|
||||
|
||||
var customer = await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Description = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
Email = organization.BillingEmail,
|
||||
Expand = ["tax", "tax_ids"]
|
||||
});
|
||||
|
||||
var plan = StaticStore.GetPlan(organization.PlanType).PasswordManager;
|
||||
var plan = await _pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
Customer = organization.GatewayCustomerId,
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30,
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true },
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "organizationId", organization.Id.ToString() }
|
||||
},
|
||||
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 }]
|
||||
};
|
||||
|
||||
subscriptionCreateOptions.AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true };
|
||||
|
||||
var subscription = await _stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
organization.GatewaySubscriptionId = subscription.Id;
|
||||
organization.Status = OrganizationStatusType.Created;
|
||||
organization.Enabled = true;
|
||||
|
||||
await _providerBillingService.ScaleSeats(provider, organization.PlanType, -organization.Seats ?? 0);
|
||||
}
|
||||
else if (organization.IsStripeEnabled())
|
||||
{
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId);
|
||||
var subscription = await _stripeAdapter.SubscriptionGetAsync(organization.GatewaySubscriptionId, new SubscriptionGetOptions
|
||||
{
|
||||
Expand = ["customer"]
|
||||
});
|
||||
|
||||
if (subscription.Status is StripeConstants.SubscriptionStatus.Canceled or StripeConstants.SubscriptionStatus.IncompleteExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, new CustomerUpdateOptions
|
||||
await _stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
Coupon = string.Empty,
|
||||
Email = organization.BillingEmail
|
||||
});
|
||||
|
||||
if (subscription.Customer.Discount?.Coupon != null)
|
||||
{
|
||||
await _stripeAdapter.CustomerDeleteDiscountAsync(subscription.CustomerId);
|
||||
}
|
||||
|
||||
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
CollectionMethod = StripeConstants.CollectionMethod.SendInvoice,
|
||||
DaysUntilDue = 30
|
||||
DaysUntilDue = 30,
|
||||
});
|
||||
|
||||
await _subscriberService.RemovePaymentSource(organization);
|
||||
@@ -161,7 +180,7 @@ public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProv
|
||||
await _mailService.SendProviderUpdatePaymentMethod(
|
||||
organization.Id,
|
||||
organization.Name,
|
||||
provider.Name,
|
||||
provider.Name!,
|
||||
organizationOwnerEmails);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
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.OrganizationFeatures.Organizations;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
@@ -28,7 +33,12 @@ 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.FamiliesAnnually2025,
|
||||
PlanType.FamiliesAnnually2019,
|
||||
PlanType.FamiliesAnnually
|
||||
];
|
||||
|
||||
private readonly IDataProtector _dataProtector;
|
||||
private readonly IMailService _mailService;
|
||||
@@ -47,6 +57,8 @@ public class ProviderService : IProviderService
|
||||
private readonly IDataProtectorTokenFactory<ProviderDeleteTokenable> _providerDeleteTokenDataFactory;
|
||||
private readonly IApplicationCacheService _applicationCacheService;
|
||||
private readonly IProviderBillingService _providerBillingService;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
private readonly IProviderClientOrganizationSignUpCommand _providerClientOrganizationSignUpCommand;
|
||||
|
||||
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
|
||||
@@ -55,7 +67,8 @@ public class ProviderService : IProviderService
|
||||
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
|
||||
ICurrentContext currentContext, IStripeAdapter stripeAdapter, IFeatureService featureService,
|
||||
IDataProtectorTokenFactory<ProviderDeleteTokenable> providerDeleteTokenDataFactory,
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService)
|
||||
IApplicationCacheService applicationCacheService, IProviderBillingService providerBillingService, IPricingClient pricingClient,
|
||||
IProviderClientOrganizationSignUpCommand providerClientOrganizationSignUpCommand)
|
||||
{
|
||||
_providerRepository = providerRepository;
|
||||
_providerUserRepository = providerUserRepository;
|
||||
@@ -74,9 +87,11 @@ public class ProviderService : IProviderService
|
||||
_providerDeleteTokenDataFactory = providerDeleteTokenDataFactory;
|
||||
_applicationCacheService = applicationCacheService;
|
||||
_providerBillingService = providerBillingService;
|
||||
_pricingClient = pricingClient;
|
||||
_providerClientOrganizationSignUpCommand = providerClientOrganizationSignUpCommand;
|
||||
}
|
||||
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TaxInfo taxInfo = null)
|
||||
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key, TokenizedPaymentMethod paymentMethod, BillingAddress billingAddress)
|
||||
{
|
||||
var owner = await _userService.GetUserByIdAsync(ownerUserId);
|
||||
if (owner == null)
|
||||
@@ -101,24 +116,12 @@ public class ProviderService : IProviderService
|
||||
throw new BadRequestException("Invalid owner.");
|
||||
}
|
||||
|
||||
if (!_featureService.IsEnabled(FeatureFlagKeys.EnableConsolidatedBilling))
|
||||
{
|
||||
provider.Status = ProviderStatusType.Created;
|
||||
await _providerRepository.UpsertAsync(provider);
|
||||
}
|
||||
else
|
||||
{
|
||||
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);
|
||||
}
|
||||
var customer = await _providerBillingService.SetupCustomer(provider, paymentMethod, billingAddress);
|
||||
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;
|
||||
await _providerUserRepository.ReplaceAsync(providerUser);
|
||||
@@ -133,7 +136,15 @@ public class ProviderService : IProviderService
|
||||
throw new ArgumentException("Cannot create provider this way.");
|
||||
}
|
||||
|
||||
var existingProvider = await _providerRepository.GetByIdAsync(provider.Id);
|
||||
var enabledStatusChanged = existingProvider != null && existingProvider.Enabled != provider.Enabled;
|
||||
|
||||
await _providerRepository.ReplaceAsync(provider);
|
||||
|
||||
if (enabledStatusChanged && (provider.Type == ProviderType.Msp || provider.Type == ProviderType.BusinessUnit))
|
||||
{
|
||||
await UpdateClientOrganizationsEnabledStatusAsync(provider.Id, provider.Enabled);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
|
||||
@@ -271,11 +282,10 @@ public class ProviderService : IProviderService
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
if (!keyedFilteredUsers.ContainsKey(user.Id))
|
||||
if (!keyedFilteredUsers.TryGetValue(user.Id, out var providerUser))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var providerUser = keyedFilteredUsers[user.Id];
|
||||
try
|
||||
{
|
||||
if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId)
|
||||
@@ -392,7 +402,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)
|
||||
{
|
||||
@@ -407,8 +419,6 @@ public class ProviderService : IProviderService
|
||||
Key = key,
|
||||
};
|
||||
|
||||
var provider = await _providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
await ApplyProviderPriceRateAsync(organization, provider);
|
||||
await _providerOrganizationRepository.CreateAsync(providerOrganization);
|
||||
|
||||
@@ -457,30 +467,31 @@ public class ProviderService : IProviderService
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(organization.GatewaySubscriptionId))
|
||||
{
|
||||
var subscriptionItem = await GetSubscriptionItemAsync(organization.GatewaySubscriptionId,
|
||||
GetStripeSeatPlanId(organization.PlanType));
|
||||
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, GetStripeSeatPlanId(extractedPlanType), organization);
|
||||
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
|
||||
{
|
||||
@@ -545,18 +556,14 @@ 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 signUpResponse = await _providerClientOrganizationSignUpCommand.SignUpClientOrganizationAsync(organizationSignup);
|
||||
|
||||
var providerOrganization = new ProviderOrganization
|
||||
{
|
||||
ProviderId = providerId,
|
||||
OrganizationId = organization.Id,
|
||||
OrganizationId = signUpResponse.Organization.Id,
|
||||
Key = organizationSignup.OwnerKey,
|
||||
};
|
||||
|
||||
@@ -565,12 +572,12 @@ public class ProviderService : IProviderService
|
||||
|
||||
// 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 = defaultCollection != null
|
||||
var defaultOwnerAccess = signUpResponse.DefaultCollection != null
|
||||
?
|
||||
[
|
||||
new CollectionAccessSelection
|
||||
{
|
||||
Id = defaultCollection.Id,
|
||||
Id = signUpResponse.DefaultCollection.Id,
|
||||
HidePasswords = false,
|
||||
ReadOnly = false,
|
||||
Manage = true
|
||||
@@ -578,7 +585,7 @@ public class ProviderService : IProviderService
|
||||
]
|
||||
: Array.Empty<CollectionAccessSelection>();
|
||||
|
||||
await _organizationService.InviteUsersAsync(organization.Id, user.Id, systemUser: null,
|
||||
await _organizationService.InviteUsersAsync(signUpResponse.Organization.Id, user.Id, systemUser: null,
|
||||
new (OrganizationUserInvite, string)[]
|
||||
{
|
||||
(
|
||||
@@ -687,16 +694,46 @@ 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.");
|
||||
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.BusinessUnit:
|
||||
if (requestedType is not (PlanType.EnterpriseMonthly or PlanType.EnterpriseAnnually))
|
||||
{
|
||||
throw new BadRequestException($"Business Unit 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}.");
|
||||
}
|
||||
}
|
||||
|
||||
if (ProviderDisallowedOrganizationTypes.Contains(requestedType))
|
||||
private async Task UpdateClientOrganizationsEnabledStatusAsync(Guid providerId, bool enabled)
|
||||
{
|
||||
var providerOrganizations = await _providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
||||
|
||||
foreach (var providerOrganization in providerOrganizations)
|
||||
{
|
||||
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
|
||||
var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
|
||||
if (organization != null && organization.Enabled != enabled)
|
||||
{
|
||||
organization.Enabled = enabled;
|
||||
await _organizationRepository.ReplaceAsync(organization);
|
||||
await _applicationCacheService.UpsertOrganizationAbilityAsync(organization);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,623 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Bit.Commercial.Core.Billing.Models;
|
||||
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.Extensions;
|
||||
using Bit.Core.Billing.Repositories;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
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(
|
||||
ICurrentContext currentContext,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPaymentService paymentService,
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IProviderBillingService
|
||||
{
|
||||
public async Task AssignSeatsToClientOrganization(
|
||||
Provider provider,
|
||||
Organization organization,
|
||||
int seats)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(organization);
|
||||
|
||||
if (seats < 0)
|
||||
{
|
||||
throw new BillingException(
|
||||
"You cannot assign negative seats to a client.",
|
||||
"MSP cannot assign negative seats to a client organization");
|
||||
}
|
||||
|
||||
if (seats == organization.Seats)
|
||||
{
|
||||
logger.LogWarning("Client organization ({ID}) already has {Seats} seats assigned to it", organization.Id, organization.Seats);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var seatAdjustment = seats - (organization.Seats ?? 0);
|
||||
|
||||
await ScaleSeats(provider, organization.PlanType, seatAdjustment);
|
||||
|
||||
organization.Seats = seats;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
public async Task<int> GetAssignedSeatTotalForPlanOrThrow(
|
||||
Guid providerId,
|
||||
PlanType planType)
|
||||
{
|
||||
var provider = await providerRepository.GetByIdAsync(providerId);
|
||||
|
||||
if (provider == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Could not find provider ({ID}) when retrieving assigned seat total",
|
||||
providerId);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (provider.Type == ProviderType.Reseller)
|
||||
{
|
||||
logger.LogError("Assigned seats cannot be retrieved for reseller-type provider ({ID})", providerId);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerOrganizations = await providerOrganizationRepository.GetManyDetailsByProviderAsync(providerId);
|
||||
|
||||
var plan = StaticStore.GetPlan(planType);
|
||||
|
||||
return providerOrganizations
|
||||
.Where(providerOrganization => providerOrganization.Plan == plan.Name && providerOrganization.Status == OrganizationStatusType.Managed)
|
||||
.Sum(providerOrganization => providerOrganization.Seats ?? 0);
|
||||
}
|
||||
|
||||
public async Task ScaleSeats(
|
||||
Provider provider,
|
||||
PlanType planType,
|
||||
int seatAdjustment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (provider.Type != ProviderType.Msp)
|
||||
{
|
||||
logger.LogError("Non-MSP provider ({ProviderID}) cannot scale their seats", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
if (!planType.SupportsConsolidatedBilling())
|
||||
{
|
||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} as it does not support consolidated billing", provider.Id, planType.ToString());
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var providerPlan = providerPlans.FirstOrDefault(providerPlan => providerPlan.PlanType == planType);
|
||||
|
||||
if (providerPlan == null || !providerPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot scale provider ({ProviderID}) seats for plan type {PlanType} when their matching provider plan is not configured", provider.Id, planType);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var seatMinimum = providerPlan.SeatMinimum.GetValueOrDefault(0);
|
||||
|
||||
var currentlyAssignedSeatTotal = await GetAssignedSeatTotalForPlanOrThrow(provider.Id, 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)
|
||||
{
|
||||
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
||||
{
|
||||
logger.LogError("Service user for provider ({ProviderID}) cannot scale a provider's seat count over the seat minimum", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
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<Customer> SetupCustomer(
|
||||
Provider provider,
|
||||
TaxInfo taxInfo)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
ArgumentNullException.ThrowIfNull(taxInfo);
|
||||
|
||||
if (string.IsNullOrEmpty(taxInfo.BillingAddressCountry) ||
|
||||
string.IsNullOrEmpty(taxInfo.BillingAddressPostalCode))
|
||||
{
|
||||
logger.LogError("Cannot create customer for provider ({ProviderID}) without both a country and postal code", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var providerDisplayName = provider.DisplayName();
|
||||
|
||||
var customerCreateOptions = 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 = providerDisplayName?.Length <= 30
|
||||
? providerDisplayName
|
||||
: providerDisplayName?[..30]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
{ "region", globalSettings.BaseServiceUri.CloudRegion }
|
||||
},
|
||||
TaxIdData = taxInfo.HasTaxId ?
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = taxInfo.TaxIdType, Value = taxInfo.TaxIdNumber }
|
||||
]
|
||||
: null
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(customerCreateOptions);
|
||||
}
|
||||
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>();
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (teamsProviderPlan == null || !teamsProviderPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Teams plan", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var teamsPlan = StaticStore.GetPlan(PlanType.TeamsMonthly);
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = teamsPlan.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Quantity = teamsProviderPlan.SeatMinimum
|
||||
});
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.SingleOrDefault(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
if (enterpriseProviderPlan == null || !enterpriseProviderPlan.IsConfigured())
|
||||
{
|
||||
logger.LogError("Cannot start subscription for provider ({ProviderID}) that has no configured Enterprise plan", provider.Id);
|
||||
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
var enterprisePlan = StaticStore.GetPlan(PlanType.EnterpriseMonthly);
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = enterprisePlan.PasswordManager.StripeProviderPortalSeatPlanId,
|
||||
Quantity = enterpriseProviderPlan.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(
|
||||
Provider provider,
|
||||
int enterpriseSeatMinimum,
|
||||
int teamsSeatMinimum)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
if (enterpriseSeatMinimum < 0 || teamsSeatMinimum < 0)
|
||||
{
|
||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||
}
|
||||
|
||||
var subscription = await stripeAdapter.SubscriptionGetAsync(provider.GatewaySubscriptionId);
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
var enterpriseProviderPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.EnterpriseMonthly);
|
||||
|
||||
if (enterpriseProviderPlan.SeatMinimum != enterpriseSeatMinimum)
|
||||
{
|
||||
var enterprisePriceId = StaticStore.GetPlan(PlanType.EnterpriseMonthly).PasswordManager
|
||||
.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var enterpriseSubscriptionItem = subscription.Items.First(item => item.Price.Id == enterprisePriceId);
|
||||
|
||||
if (enterpriseProviderPlan.PurchasedSeats == 0)
|
||||
{
|
||||
if (enterpriseProviderPlan.AllocatedSeats > enterpriseSeatMinimum)
|
||||
{
|
||||
enterpriseProviderPlan.PurchasedSeats =
|
||||
enterpriseProviderPlan.AllocatedSeats - enterpriseSeatMinimum;
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = enterpriseSubscriptionItem.Id,
|
||||
Price = enterprisePriceId,
|
||||
Quantity = enterpriseProviderPlan.AllocatedSeats
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = enterpriseSubscriptionItem.Id,
|
||||
Price = enterprisePriceId,
|
||||
Quantity = enterpriseSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalEnterpriseSeats = enterpriseProviderPlan.SeatMinimum + enterpriseProviderPlan.PurchasedSeats;
|
||||
|
||||
if (enterpriseSeatMinimum <= totalEnterpriseSeats)
|
||||
{
|
||||
enterpriseProviderPlan.PurchasedSeats = totalEnterpriseSeats - enterpriseSeatMinimum;
|
||||
}
|
||||
else
|
||||
{
|
||||
enterpriseProviderPlan.PurchasedSeats = 0;
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = enterpriseSubscriptionItem.Id,
|
||||
Price = enterprisePriceId,
|
||||
Quantity = enterpriseSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enterpriseProviderPlan.SeatMinimum = enterpriseSeatMinimum;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(enterpriseProviderPlan);
|
||||
}
|
||||
|
||||
var teamsProviderPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == PlanType.TeamsMonthly);
|
||||
|
||||
if (teamsProviderPlan.SeatMinimum != teamsSeatMinimum)
|
||||
{
|
||||
var teamsPriceId = StaticStore.GetPlan(PlanType.TeamsMonthly).PasswordManager
|
||||
.StripeProviderPortalSeatPlanId;
|
||||
|
||||
var teamsSubscriptionItem = subscription.Items.First(item => item.Price.Id == teamsPriceId);
|
||||
|
||||
if (teamsProviderPlan.PurchasedSeats == 0)
|
||||
{
|
||||
if (teamsProviderPlan.AllocatedSeats > teamsSeatMinimum)
|
||||
{
|
||||
teamsProviderPlan.PurchasedSeats = teamsProviderPlan.AllocatedSeats - teamsSeatMinimum;
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = teamsSubscriptionItem.Id,
|
||||
Price = teamsPriceId,
|
||||
Quantity = teamsProviderPlan.AllocatedSeats
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = teamsSubscriptionItem.Id,
|
||||
Price = teamsPriceId,
|
||||
Quantity = teamsSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalTeamsSeats = teamsProviderPlan.SeatMinimum + teamsProviderPlan.PurchasedSeats;
|
||||
|
||||
if (teamsSeatMinimum <= totalTeamsSeats)
|
||||
{
|
||||
teamsProviderPlan.PurchasedSeats = totalTeamsSeats - teamsSeatMinimum;
|
||||
}
|
||||
else
|
||||
{
|
||||
teamsProviderPlan.PurchasedSeats = 0;
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = teamsSubscriptionItem.Id,
|
||||
Price = teamsPriceId,
|
||||
Quantity = teamsSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
teamsProviderPlan.SeatMinimum = teamsSeatMinimum;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(teamsProviderPlan);
|
||||
}
|
||||
|
||||
if (subscriptionItemOptionsList.Count > 0)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||
}
|
||||
}
|
||||
|
||||
private Func<int, int, Task> CurrySeatScalingUpdate(
|
||||
Provider provider,
|
||||
ProviderPlan providerPlan,
|
||||
int newlyAssignedSeats) => async (currentlySubscribedSeats, newlySubscribedSeats) =>
|
||||
{
|
||||
var plan = StaticStore.GetPlan(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);
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Models;
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Models;
|
||||
|
||||
public class ProviderClientInvoiceReportRow
|
||||
{
|
||||
@@ -0,0 +1,107 @@
|
||||
using Bit.Core.AdminConsole.Entities.Provider;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Providers.Models;
|
||||
using Bit.Core.Billing.Providers.Queries;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Services;
|
||||
using Stripe;
|
||||
using Stripe.Tax;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Queries;
|
||||
|
||||
using static Bit.Core.Constants;
|
||||
using static StripeConstants;
|
||||
using SuspensionWarning = ProviderWarnings.SuspensionWarning;
|
||||
using TaxIdWarning = ProviderWarnings.TaxIdWarning;
|
||||
|
||||
public class GetProviderWarningsQuery(
|
||||
ICurrentContext currentContext,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService) : IGetProviderWarningsQuery
|
||||
{
|
||||
public async Task<ProviderWarnings?> Run(Provider provider)
|
||||
{
|
||||
var warnings = new ProviderWarnings();
|
||||
|
||||
var subscription =
|
||||
await subscriberService.GetSubscription(provider,
|
||||
new SubscriptionGetOptions { Expand = ["customer.tax_ids"] });
|
||||
|
||||
if (subscription == null)
|
||||
{
|
||||
return warnings;
|
||||
}
|
||||
|
||||
warnings.Suspension = GetSuspensionWarning(provider, subscription);
|
||||
|
||||
warnings.TaxId = await GetTaxIdWarningAsync(provider, subscription.Customer);
|
||||
|
||||
return warnings;
|
||||
}
|
||||
|
||||
private SuspensionWarning? GetSuspensionWarning(
|
||||
Provider provider,
|
||||
Subscription subscription)
|
||||
{
|
||||
if (provider.Enabled)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscription.Status switch
|
||||
{
|
||||
SubscriptionStatus.Unpaid => currentContext.ProviderProviderAdmin(provider.Id)
|
||||
? new SuspensionWarning { Resolution = "add_payment_method", SubscriptionCancelsAt = subscription.CancelAt }
|
||||
: new SuspensionWarning { Resolution = "contact_administrator" },
|
||||
_ => new SuspensionWarning { Resolution = "contact_support" }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<TaxIdWarning?> GetTaxIdWarningAsync(
|
||||
Provider provider,
|
||||
Customer customer)
|
||||
{
|
||||
if (customer.Address?.Country == CountryAbbreviations.UnitedStates)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!currentContext.ProviderProviderAdmin(provider.Id))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO: Potentially DRY this out with the GetOrganizationWarningsQuery
|
||||
|
||||
// Get active and scheduled registrations
|
||||
var registrations = (await Task.WhenAll(
|
||||
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Active }),
|
||||
stripeAdapter.TaxRegistrationsListAsync(new RegistrationListOptions { Status = TaxRegistrationStatus.Scheduled })))
|
||||
.SelectMany(registrations => registrations.Data);
|
||||
|
||||
// Find the matching registration for the customer
|
||||
var registration = registrations.FirstOrDefault(registration => registration.Country == customer.Address?.Country);
|
||||
|
||||
// If we're not registered in their country, we don't need a warning
|
||||
if (registration == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var taxId = customer.TaxIds.FirstOrDefault();
|
||||
|
||||
return taxId switch
|
||||
{
|
||||
// Customer's tax ID is missing
|
||||
null => new TaxIdWarning { Type = "tax_id_missing" },
|
||||
// Not sure if this case is valid, but Stripe says this property is nullable
|
||||
not null when taxId.Verification == null => null,
|
||||
// Customer's tax ID is pending verification
|
||||
not null when taxId.Verification.Status == TaxIdVerificationStatus.Pending => new TaxIdWarning { Type = "tax_id_pending_verification" },
|
||||
// Customer's tax ID failed verification
|
||||
not null when taxId.Verification.Status == TaxIdVerificationStatus.Unverified => new TaxIdWarning { Type = "tax_id_failed_verification" },
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
#nullable enable
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
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.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Entities;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Bit.Core.Utilities;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OneOf;
|
||||
using Stripe;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Services;
|
||||
|
||||
public class BusinessUnitConverter(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
GlobalSettings globalSettings,
|
||||
ILogger<BusinessUnitConverter> logger,
|
||||
IMailService mailService,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IPricingClient pricingClient,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderRepository providerRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService,
|
||||
IUserRepository userRepository) : IBusinessUnitConverter
|
||||
{
|
||||
private readonly IDataProtector _dataProtector =
|
||||
dataProtectionProvider.CreateProtector($"{nameof(BusinessUnitConverter)}DataProtector");
|
||||
|
||||
public async Task<Guid> FinalizeConversion(
|
||||
Organization organization,
|
||||
Guid userId,
|
||||
string token,
|
||||
string providerKey,
|
||||
string organizationKey)
|
||||
{
|
||||
var user = await userRepository.GetByIdAsync(userId);
|
||||
|
||||
var (subscription, provider, providerOrganization, providerUser) = await ValidateFinalizationAsync(organization, user, token);
|
||||
|
||||
var existingPlan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
var updatedPlan = await pricingClient.GetPlanOrThrow(existingPlan.IsAnnual ? PlanType.EnterpriseAnnually : PlanType.EnterpriseMonthly);
|
||||
|
||||
// Bring organization under management.
|
||||
organization.Plan = updatedPlan.Name;
|
||||
organization.PlanType = updatedPlan.Type;
|
||||
organization.MaxCollections = updatedPlan.PasswordManager.MaxCollections;
|
||||
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
|
||||
organization.UsePolicies = updatedPlan.HasPolicies;
|
||||
organization.UseSso = updatedPlan.HasSso;
|
||||
organization.UseOrganizationDomains = updatedPlan.HasOrganizationDomains;
|
||||
organization.UseGroups = updatedPlan.HasGroups;
|
||||
organization.UseEvents = updatedPlan.HasEvents;
|
||||
organization.UseDirectory = updatedPlan.HasDirectory;
|
||||
organization.UseTotp = updatedPlan.HasTotp;
|
||||
organization.Use2fa = updatedPlan.Has2fa;
|
||||
organization.UseApi = updatedPlan.HasApi;
|
||||
organization.UseResetPassword = updatedPlan.HasResetPassword;
|
||||
organization.SelfHost = updatedPlan.HasSelfHost;
|
||||
organization.UsersGetPremium = updatedPlan.UsersGetPremium;
|
||||
organization.UseCustomPermissions = updatedPlan.HasCustomPermissions;
|
||||
organization.UseScim = updatedPlan.HasScim;
|
||||
organization.UseKeyConnector = updatedPlan.HasKeyConnector;
|
||||
organization.MaxStorageGb = updatedPlan.PasswordManager.BaseStorageGb;
|
||||
organization.BillingEmail = provider.BillingEmail!;
|
||||
organization.GatewayCustomerId = null;
|
||||
organization.GatewaySubscriptionId = null;
|
||||
organization.ExpirationDate = null;
|
||||
organization.MaxAutoscaleSeats = null;
|
||||
organization.Status = OrganizationStatusType.Managed;
|
||||
|
||||
// Enable organization access via key exchange.
|
||||
providerOrganization.Key = organizationKey;
|
||||
|
||||
// Complete provider setup.
|
||||
provider.Gateway = GatewayType.Stripe;
|
||||
provider.GatewayCustomerId = subscription.CustomerId;
|
||||
provider.GatewaySubscriptionId = subscription.Id;
|
||||
provider.Status = ProviderStatusType.Billable;
|
||||
|
||||
// Enable provider access via key exchange.
|
||||
providerUser.Key = providerKey;
|
||||
providerUser.Status = ProviderUserStatusType.Confirmed;
|
||||
|
||||
// Stripe requires that we clear all the custom fields from the invoice settings if we want to replace them.
|
||||
await stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = []
|
||||
}
|
||||
});
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
[StripeConstants.MetadataKeys.OrganizationId] = string.Empty,
|
||||
[StripeConstants.MetadataKeys.ProviderId] = provider.Id.ToString(),
|
||||
["convertedFrom"] = organization.Id.ToString()
|
||||
};
|
||||
|
||||
var updateCustomer = stripeAdapter.CustomerUpdateAsync(subscription.CustomerId, new CustomerUpdateOptions
|
||||
{
|
||||
InvoiceSettings = new CustomerInvoiceSettingsOptions
|
||||
{
|
||||
CustomFields = [
|
||||
new CustomerInvoiceSettingsCustomFieldOptions
|
||||
{
|
||||
Name = provider.SubscriberType(),
|
||||
Value = provider.DisplayName()?.Length <= 30
|
||||
? provider.DisplayName()
|
||||
: provider.DisplayName()?[..30]
|
||||
}
|
||||
]
|
||||
},
|
||||
Metadata = metadata
|
||||
});
|
||||
|
||||
// Find the existing password manager price on the subscription.
|
||||
var passwordManagerItem = subscription.Items.First(item =>
|
||||
{
|
||||
var priceId = existingPlan.HasNonSeatBasedPasswordManagerPlan()
|
||||
? existingPlan.PasswordManager.StripePlanId
|
||||
: existingPlan.PasswordManager.StripeSeatPlanId;
|
||||
|
||||
return item.Price.Id == priceId;
|
||||
});
|
||||
|
||||
// Get the new business unit price.
|
||||
var updatedPriceId = ProviderPriceAdapter.GetActivePriceId(provider, updatedPlan.Type);
|
||||
|
||||
// Replace the existing password manager price with the new business unit price.
|
||||
var updateSubscription =
|
||||
stripeAdapter.SubscriptionUpdateAsync(subscription.Id,
|
||||
new SubscriptionUpdateOptions
|
||||
{
|
||||
Items = [
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Id = passwordManagerItem.Id,
|
||||
Deleted = true
|
||||
},
|
||||
new SubscriptionItemOptions
|
||||
{
|
||||
Price = updatedPriceId,
|
||||
Quantity = organization.Seats
|
||||
}
|
||||
],
|
||||
Metadata = metadata
|
||||
});
|
||||
|
||||
await Task.WhenAll(updateCustomer, updateSubscription);
|
||||
|
||||
// Complete database updates for provider setup.
|
||||
await Task.WhenAll(
|
||||
organizationRepository.ReplaceAsync(organization),
|
||||
providerOrganizationRepository.ReplaceAsync(providerOrganization),
|
||||
providerRepository.ReplaceAsync(provider),
|
||||
providerUserRepository.ReplaceAsync(providerUser));
|
||||
|
||||
return provider.Id;
|
||||
}
|
||||
|
||||
public async Task<OneOf<Guid, List<string>>> InitiateConversion(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
|
||||
|
||||
var problems = await ValidateInitiationAsync(organization, user);
|
||||
|
||||
if (problems is { Count: > 0 })
|
||||
{
|
||||
return problems;
|
||||
}
|
||||
|
||||
var provider = await providerRepository.CreateAsync(new Provider
|
||||
{
|
||||
Name = organization.Name,
|
||||
BillingEmail = organization.BillingEmail,
|
||||
Status = ProviderStatusType.Pending,
|
||||
UseEvents = true,
|
||||
Type = ProviderType.BusinessUnit
|
||||
});
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var managedPlanType = plan.IsAnnual
|
||||
? PlanType.EnterpriseAnnually
|
||||
: PlanType.EnterpriseMonthly;
|
||||
|
||||
var createProviderOrganization = providerOrganizationRepository.CreateAsync(new ProviderOrganization
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
OrganizationId = organization.Id
|
||||
});
|
||||
|
||||
var createProviderPlan = providerPlanRepository.CreateAsync(new ProviderPlan
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
PlanType = managedPlanType,
|
||||
SeatMinimum = 0,
|
||||
PurchasedSeats = organization.Seats,
|
||||
AllocatedSeats = organization.Seats
|
||||
});
|
||||
|
||||
var createProviderUser = providerUserRepository.CreateAsync(new ProviderUser
|
||||
{
|
||||
ProviderId = provider.Id,
|
||||
UserId = user!.Id,
|
||||
Email = user.Email,
|
||||
Status = ProviderUserStatusType.Invited,
|
||||
Type = ProviderUserType.ProviderAdmin
|
||||
});
|
||||
|
||||
await Task.WhenAll(createProviderOrganization, createProviderPlan, createProviderUser);
|
||||
|
||||
await SendInviteAsync(organization, user.Email);
|
||||
|
||||
return provider.Id;
|
||||
}
|
||||
|
||||
public Task ResendConversionInvite(
|
||||
Organization organization,
|
||||
string providerAdminEmail) =>
|
||||
IfConversionInProgressAsync(organization, providerAdminEmail,
|
||||
async (_, _, providerUser) =>
|
||||
{
|
||||
if (!string.IsNullOrEmpty(providerUser.Email))
|
||||
{
|
||||
await SendInviteAsync(organization, providerUser.Email);
|
||||
}
|
||||
});
|
||||
|
||||
public Task ResetConversion(
|
||||
Organization organization,
|
||||
string providerAdminEmail) =>
|
||||
IfConversionInProgressAsync(organization, providerAdminEmail,
|
||||
async (provider, providerOrganization, providerUser) =>
|
||||
{
|
||||
var tasks = new List<Task>
|
||||
{
|
||||
providerOrganizationRepository.DeleteAsync(providerOrganization),
|
||||
providerUserRepository.DeleteAsync(providerUser)
|
||||
};
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
if (providerPlans is { Count: > 0 })
|
||||
{
|
||||
tasks.AddRange(providerPlans.Select(providerPlanRepository.DeleteAsync));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
await providerRepository.DeleteAsync(provider);
|
||||
});
|
||||
|
||||
#region Utilities
|
||||
|
||||
private async Task IfConversionInProgressAsync(
|
||||
Organization organization,
|
||||
string providerAdminEmail,
|
||||
Func<Provider, ProviderOrganization, ProviderUser, Task> callback)
|
||||
{
|
||||
var user = await userRepository.GetByEmailAsync(providerAdminEmail);
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Pending
|
||||
})
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
|
||||
|
||||
if (providerUser is
|
||||
{
|
||||
Type: ProviderUserType.ProviderAdmin,
|
||||
Status: ProviderUserStatusType.Invited
|
||||
})
|
||||
{
|
||||
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||
await callback(provider, providerOrganization!, providerUser);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendInviteAsync(
|
||||
Organization organization,
|
||||
string providerAdminEmail)
|
||||
{
|
||||
var token = _dataProtector.Protect(
|
||||
$"BusinessUnitConversionInvite {organization.Id} {providerAdminEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
|
||||
|
||||
await mailService.SendBusinessUnitConversionInviteAsync(organization, token, providerAdminEmail);
|
||||
}
|
||||
|
||||
private async Task<(Subscription, Provider, ProviderOrganization, ProviderUser)> ValidateFinalizationAsync(
|
||||
Organization organization,
|
||||
User? user,
|
||||
string token)
|
||||
{
|
||||
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
|
||||
{
|
||||
Fail("Organization must be on an enterprise plan.");
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status:
|
||||
StripeConstants.SubscriptionStatus.Active or
|
||||
StripeConstants.SubscriptionStatus.Trialing or
|
||||
StripeConstants.SubscriptionStatus.PastDue
|
||||
})
|
||||
{
|
||||
Fail("Organization must have a valid subscription.");
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
Fail("Provider admin must be a Bitwarden user.");
|
||||
}
|
||||
|
||||
if (!CoreHelpers.TokenIsValid(
|
||||
"BusinessUnitConversionInvite",
|
||||
_dataProtector,
|
||||
token,
|
||||
user.Email,
|
||||
organization.Id,
|
||||
globalSettings.OrganizationInviteExpirationHours))
|
||||
{
|
||||
Fail("Email token is invalid.");
|
||||
}
|
||||
|
||||
var organizationUser =
|
||||
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||
|
||||
if (organizationUser is not
|
||||
{
|
||||
Status: OrganizationUserStatusType.Confirmed
|
||||
})
|
||||
{
|
||||
Fail("Provider admin must be a confirmed member of the organization being converted.");
|
||||
}
|
||||
|
||||
var provider = await providerRepository.GetByOrganizationIdAsync(organization.Id);
|
||||
|
||||
if (provider is not
|
||||
{
|
||||
Type: ProviderType.BusinessUnit,
|
||||
Status: ProviderStatusType.Pending
|
||||
})
|
||||
{
|
||||
Fail("Linked provider is not a pending business unit.");
|
||||
}
|
||||
|
||||
var providerUser = await providerUserRepository.GetByProviderUserAsync(provider.Id, user.Id);
|
||||
|
||||
if (providerUser is not
|
||||
{
|
||||
Type: ProviderUserType.ProviderAdmin,
|
||||
Status: ProviderUserStatusType.Invited
|
||||
})
|
||||
{
|
||||
Fail("Provider admin has not been invited.");
|
||||
}
|
||||
|
||||
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||
|
||||
return (subscription, provider, providerOrganization!, providerUser);
|
||||
|
||||
[DoesNotReturn]
|
||||
void Fail(string scopedError)
|
||||
{
|
||||
logger.LogError("Could not finalize business unit conversion for organization ({OrganizationID}): {Error}",
|
||||
organization.Id, scopedError);
|
||||
throw new BillingException();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<string>?> ValidateInitiationAsync(
|
||||
Organization organization,
|
||||
User? user)
|
||||
{
|
||||
var problems = new List<string>();
|
||||
|
||||
if (organization.PlanType.GetProductTier() != ProductTierType.Enterprise)
|
||||
{
|
||||
problems.Add("Organization must be on an enterprise plan.");
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscription(organization);
|
||||
|
||||
if (subscription is not
|
||||
{
|
||||
Status:
|
||||
StripeConstants.SubscriptionStatus.Active or
|
||||
StripeConstants.SubscriptionStatus.Trialing or
|
||||
StripeConstants.SubscriptionStatus.PastDue
|
||||
})
|
||||
{
|
||||
problems.Add("Organization must have a valid subscription.");
|
||||
}
|
||||
|
||||
var providerOrganization = await providerOrganizationRepository.GetByOrganizationId(organization.Id);
|
||||
|
||||
if (providerOrganization != null)
|
||||
{
|
||||
problems.Add("Organization is already linked to a provider.");
|
||||
}
|
||||
|
||||
if (user == null)
|
||||
{
|
||||
problems.Add("Provider admin must be a Bitwarden user.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var organizationUser =
|
||||
await organizationUserRepository.GetByOrganizationAsync(organization.Id, user.Id);
|
||||
|
||||
if (organizationUser is not
|
||||
{
|
||||
Status: OrganizationUserStatusType.Confirmed
|
||||
})
|
||||
{
|
||||
problems.Add("Provider admin must be a confirmed member of the organization being converted.");
|
||||
}
|
||||
}
|
||||
|
||||
return problems.Count == 0 ? null : problems;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Globalization;
|
||||
using Bit.Commercial.Core.Billing.Providers.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.Caches;
|
||||
using Bit.Core.Billing.Constants;
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Extensions;
|
||||
using Bit.Core.Billing.Models;
|
||||
using Bit.Core.Billing.Payment.Models;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Billing.Providers.Entities;
|
||||
using Bit.Core.Billing.Providers.Models;
|
||||
using Bit.Core.Billing.Providers.Repositories;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Tax.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Core.Settings;
|
||||
using Braintree;
|
||||
using CsvHelper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Stripe;
|
||||
using static Bit.Core.Billing.Utilities;
|
||||
using Customer = Stripe.Customer;
|
||||
using Subscription = Stripe.Subscription;
|
||||
|
||||
namespace Bit.Commercial.Core.Billing.Providers.Services;
|
||||
|
||||
using static Constants;
|
||||
using static StripeConstants;
|
||||
|
||||
public class ProviderBillingService(
|
||||
IBraintreeGateway braintreeGateway,
|
||||
IEventService eventService,
|
||||
IGlobalSettings globalSettings,
|
||||
ILogger<ProviderBillingService> logger,
|
||||
IOrganizationRepository organizationRepository,
|
||||
IPricingClient pricingClient,
|
||||
IProviderInvoiceItemRepository providerInvoiceItemRepository,
|
||||
IProviderOrganizationRepository providerOrganizationRepository,
|
||||
IProviderPlanRepository providerPlanRepository,
|
||||
IProviderUserRepository providerUserRepository,
|
||||
ISetupIntentCache setupIntentCache,
|
||||
IStripeAdapter stripeAdapter,
|
||||
ISubscriberService subscriberService)
|
||||
: IProviderBillingService
|
||||
{
|
||||
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 == 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.UseOrganizationDomains = plan.HasOrganizationDomains;
|
||||
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 (provider, providerPlanId, newPlanType) = command;
|
||||
|
||||
var providerPlan = await providerPlanRepository.GetByIdAsync(providerPlanId);
|
||||
|
||||
if (providerPlan == null)
|
||||
{
|
||||
throw new BadRequestException("Provider plan not found.");
|
||||
}
|
||||
|
||||
if (providerPlan.PlanType == newPlanType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
var oldPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
var newPriceId = ProviderPriceAdapter.GetPriceId(provider, subscription, newPlanType);
|
||||
|
||||
providerPlan.PlanType = newPlanType;
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
|
||||
var oldSubscriptionItem = subscription.Items.SingleOrDefault(x => x.Price.Id == oldPriceId);
|
||||
|
||||
var updateOptions = new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions { Price = newPriceId, Quantity = oldSubscriptionItem!.Quantity },
|
||||
new SubscriptionItemOptions { Id = oldSubscriptionItem.Id, Deleted = true }
|
||||
]
|
||||
};
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.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(providerPlan.ProviderId);
|
||||
|
||||
var newPlan = await pricingClient.GetPlanOrThrow(newPlanType);
|
||||
|
||||
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 = newPlanType;
|
||||
organization.Plan = newPlan.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", "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 }
|
||||
]
|
||||
};
|
||||
|
||||
if (providerCustomer.Address is not { Country: CountryAbbreviations.UnitedStates })
|
||||
{
|
||||
customerCreateOptions.TaxExempt = TaxExempt.Reverse;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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:
|
||||
SubscriptionStatus.Active or
|
||||
SubscriptionStatus.Trialing or
|
||||
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 scaleQuantityTo = 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 scaleQuantityTo(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 scaleQuantityTo(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 scaleQuantityTo(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,
|
||||
TokenizedPaymentMethod paymentMethod,
|
||||
BillingAddress billingAddress)
|
||||
{
|
||||
var options = new CustomerCreateOptions
|
||||
{
|
||||
Address = new AddressOptions
|
||||
{
|
||||
Country = billingAddress.Country,
|
||||
PostalCode = billingAddress.PostalCode,
|
||||
Line1 = billingAddress.Line1,
|
||||
Line2 = billingAddress.Line2,
|
||||
City = billingAddress.City,
|
||||
State = billingAddress.State
|
||||
},
|
||||
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 } },
|
||||
TaxExempt = billingAddress.Country != CountryAbbreviations.UnitedStates ? TaxExempt.Reverse : TaxExempt.None
|
||||
};
|
||||
|
||||
if (billingAddress.TaxId != null)
|
||||
{
|
||||
options.TaxIdData =
|
||||
[
|
||||
new CustomerTaxIdDataOptions { Type = billingAddress.TaxId.Code, Value = billingAddress.TaxId.Value }
|
||||
];
|
||||
|
||||
if (billingAddress.TaxId.Code == TaxIdType.SpanishNIF)
|
||||
{
|
||||
options.TaxIdData.Add(new CustomerTaxIdDataOptions
|
||||
{
|
||||
Type = TaxIdType.EUVAT,
|
||||
Value = $"ES{billingAddress.TaxId.Value}"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var braintreeCustomerId = "";
|
||||
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (paymentMethod.Type)
|
||||
{
|
||||
case TokenizablePaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntent =
|
||||
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions
|
||||
{
|
||||
PaymentMethod = paymentMethod.Token
|
||||
}))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (setupIntent == null)
|
||||
{
|
||||
logger.LogError(
|
||||
"Cannot create customer for provider ({ProviderID}) without a setup intent for their bank account",
|
||||
provider.Id);
|
||||
throw new BillingException();
|
||||
}
|
||||
|
||||
await setupIntentCache.Set(provider.Id, setupIntent.Id);
|
||||
break;
|
||||
}
|
||||
case TokenizablePaymentMethodType.Card:
|
||||
{
|
||||
options.PaymentMethod = paymentMethod.Token;
|
||||
options.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
|
||||
break;
|
||||
}
|
||||
case TokenizablePaymentMethodType.PayPal:
|
||||
{
|
||||
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(provider, paymentMethod.Token);
|
||||
options.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await stripeAdapter.CustomerCreateAsync(options);
|
||||
}
|
||||
catch (StripeException stripeException) when (stripeException.StripeError?.Code == ErrorCodes.TaxIdInvalid)
|
||||
{
|
||||
await Revert();
|
||||
throw new BadRequestException(
|
||||
"Your tax ID wasn't recognized for your selected country. Please ensure your country and tax ID are valid.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
await Revert();
|
||||
throw;
|
||||
}
|
||||
|
||||
async Task Revert()
|
||||
{
|
||||
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
|
||||
switch (paymentMethod.Type)
|
||||
{
|
||||
case TokenizablePaymentMethodType.BankAccount:
|
||||
{
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
|
||||
await stripeAdapter.SetupIntentCancel(setupIntentId,
|
||||
new SetupIntentCancelOptions { CancellationReason = "abandoned" });
|
||||
await setupIntentCache.RemoveSetupIntentForSubscriber(provider.Id);
|
||||
break;
|
||||
}
|
||||
case TokenizablePaymentMethodType.PayPal when !string.IsNullOrEmpty(braintreeCustomerId):
|
||||
{
|
||||
await braintreeGateway.Customer.DeleteAsync(braintreeCustomerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Subscription> SetupSubscription(
|
||||
Provider provider)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(provider);
|
||||
|
||||
var customerGetOptions = new CustomerGetOptions { Expand = ["tax", "tax_ids"] };
|
||||
var customer = await subscriberService.GetCustomerOrThrow(provider, customerGetOptions);
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
if (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();
|
||||
}
|
||||
|
||||
var priceId = ProviderPriceAdapter.GetActivePriceId(provider, providerPlan.PlanType);
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Price = priceId,
|
||||
Quantity = providerPlan.SeatMinimum
|
||||
});
|
||||
}
|
||||
|
||||
var setupIntentId = await setupIntentCache.GetSetupIntentIdForSubscriber(provider.Id);
|
||||
|
||||
var setupIntent = !string.IsNullOrEmpty(setupIntentId)
|
||||
? await stripeAdapter.SetupIntentGet(setupIntentId,
|
||||
new SetupIntentGetOptions { Expand = ["payment_method"] })
|
||||
: null;
|
||||
|
||||
var usePaymentMethod =
|
||||
!string.IsNullOrEmpty(customer.InvoiceSettings?.DefaultPaymentMethodId) ||
|
||||
customer.Metadata?.ContainsKey(BraintreeCustomerIdKey) == true ||
|
||||
setupIntent?.IsUnverifiedBankAccount() == true;
|
||||
|
||||
int? trialPeriodDays = provider.Type switch
|
||||
{
|
||||
ProviderType.Msp when usePaymentMethod => 14,
|
||||
ProviderType.BusinessUnit when usePaymentMethod => 4,
|
||||
_ => null
|
||||
};
|
||||
|
||||
var subscriptionCreateOptions = new SubscriptionCreateOptions
|
||||
{
|
||||
CollectionMethod =
|
||||
usePaymentMethod
|
||||
? CollectionMethod.ChargeAutomatically
|
||||
: CollectionMethod.SendInvoice,
|
||||
Customer = customer.Id,
|
||||
DaysUntilDue = usePaymentMethod ? null : 30,
|
||||
Discounts = !string.IsNullOrEmpty(provider.DiscountId) ? [new SubscriptionDiscountOptions { Coupon = provider.DiscountId }] : null,
|
||||
Items = subscriptionItemOptionsList,
|
||||
Metadata = new Dictionary<string, string> { { "providerId", provider.Id.ToString() } },
|
||||
OffSession = true,
|
||||
ProrationBehavior = ProrationBehavior.CreateProrations,
|
||||
TrialPeriodDays = trialPeriodDays,
|
||||
AutomaticTax = new SubscriptionAutomaticTaxOptions { Enabled = true }
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var subscription = await stripeAdapter.SubscriptionCreateAsync(subscriptionCreateOptions);
|
||||
|
||||
if (subscription is
|
||||
{
|
||||
Status: SubscriptionStatus.Active or SubscriptionStatus.Trialing
|
||||
})
|
||||
{
|
||||
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 ==
|
||||
ErrorCodes.CustomerTaxLocationInvalid)
|
||||
{
|
||||
throw new BadRequestException(
|
||||
"Your location wasn't recognized. Please ensure your country and postal code are valid.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdatePaymentMethod(
|
||||
Provider provider,
|
||||
TokenizedPaymentSource tokenizedPaymentSource,
|
||||
TaxInformation taxInformation)
|
||||
{
|
||||
await Task.WhenAll(
|
||||
subscriberService.UpdatePaymentSource(provider, tokenizedPaymentSource),
|
||||
subscriberService.UpdateTaxInformation(provider, taxInformation));
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { CollectionMethod = CollectionMethod.ChargeAutomatically });
|
||||
}
|
||||
|
||||
public async Task UpdateSeatMinimums(UpdateProviderSeatMinimumsCommand command)
|
||||
{
|
||||
var (provider, updatedPlanConfigurations) = command;
|
||||
|
||||
if (updatedPlanConfigurations.Any(x => x.SeatsMinimum < 0))
|
||||
{
|
||||
throw new BadRequestException("Provider seat minimums must be at least 0.");
|
||||
}
|
||||
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>();
|
||||
|
||||
var providerPlans = await providerPlanRepository.GetByProviderId(provider.Id);
|
||||
|
||||
foreach (var updatedPlanConfiguration in updatedPlanConfigurations)
|
||||
{
|
||||
var (updatedPlanType, updatedSeatMinimum) = updatedPlanConfiguration;
|
||||
|
||||
var providerPlan =
|
||||
providerPlans.Single(providerPlan => providerPlan.PlanType == updatedPlanType);
|
||||
|
||||
if (providerPlan.SeatMinimum != updatedSeatMinimum)
|
||||
{
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, updatedPlanType);
|
||||
|
||||
var subscriptionItem = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
if (providerPlan.PurchasedSeats == 0)
|
||||
{
|
||||
if (providerPlan.AllocatedSeats > updatedSeatMinimum)
|
||||
{
|
||||
providerPlan.PurchasedSeats = providerPlan.AllocatedSeats - updatedSeatMinimum;
|
||||
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = providerPlan.AllocatedSeats
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = updatedSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var totalSeats = providerPlan.SeatMinimum + providerPlan.PurchasedSeats;
|
||||
|
||||
if (updatedSeatMinimum <= totalSeats)
|
||||
{
|
||||
providerPlan.PurchasedSeats = totalSeats - updatedSeatMinimum;
|
||||
}
|
||||
else
|
||||
{
|
||||
providerPlan.PurchasedSeats = 0;
|
||||
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
|
||||
{
|
||||
Id = subscriptionItem.Id,
|
||||
Price = priceId,
|
||||
Quantity = updatedSeatMinimum
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
providerPlan.SeatMinimum = updatedSeatMinimum;
|
||||
|
||||
await providerPlanRepository.ReplaceAsync(providerPlan);
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriptionItemOptionsList.Count > 0)
|
||||
{
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId,
|
||||
new SubscriptionUpdateOptions { Items = subscriptionItemOptionsList });
|
||||
}
|
||||
}
|
||||
|
||||
private Func<int, Task> CurrySeatScalingUpdate(
|
||||
Provider provider,
|
||||
ProviderPlan providerPlan,
|
||||
int newlyAssignedSeats) => async newlySubscribedSeats =>
|
||||
{
|
||||
var subscription = await subscriberService.GetSubscriptionOrThrow(provider);
|
||||
|
||||
var priceId = ProviderPriceAdapter.GetPriceId(provider, subscription, providerPlan.PlanType);
|
||||
|
||||
var item = subscription.Items.First(item => item.Price.Id == priceId);
|
||||
|
||||
await stripeAdapter.SubscriptionUpdateAsync(provider.GatewaySubscriptionId, new SubscriptionUpdateOptions
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new SubscriptionItemOptions { Id = item.Id, Price = priceId, Quantity = 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.BusinessUnit)
|
||||
{
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.SecretsManager.Commands.Porting;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.SecretsManager.Commands.Porting;
|
||||
using Bit.Core.SecretsManager.Commands.Porting.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Bit.Core.Context;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Auth.Identity;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Identity;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
@@ -28,16 +31,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 +55,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()
|
||||
{
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
using Bit.Core.Repositories;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
|
||||
using Bit.Core.SecretsManager.Entities;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Services;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
|
||||
|
||||
@@ -10,15 +16,21 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
|
||||
private readonly IAccessPolicyRepository _accessPolicyRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IServiceAccountRepository _serviceAccountRepository;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ICurrentContext _currentContext;
|
||||
|
||||
public CreateServiceAccountCommand(
|
||||
IAccessPolicyRepository accessPolicyRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IServiceAccountRepository serviceAccountRepository)
|
||||
IServiceAccountRepository serviceAccountRepository,
|
||||
IEventService eventService,
|
||||
ICurrentContext currentContext)
|
||||
{
|
||||
_accessPolicyRepository = accessPolicyRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_serviceAccountRepository = serviceAccountRepository;
|
||||
_eventService = eventService;
|
||||
_currentContext = currentContext;
|
||||
}
|
||||
|
||||
public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount, Guid userId)
|
||||
@@ -35,6 +47,7 @@ public class CreateServiceAccountCommand : ICreateServiceAccountCommand
|
||||
Write = true,
|
||||
};
|
||||
await _accessPolicyRepository.CreateManyAsync(new List<BaseAccessPolicy> { accessPolicy });
|
||||
await _eventService.LogServiceAccountPeopleEventAsync(user.Id, accessPolicy, EventType.ServiceAccount_UserAdded, _currentContext.IdentityClientType);
|
||||
return createdServiceAccount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Claims;
|
||||
using Bit.Core.Context;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.SecretsManager.Queries.Interfaces;
|
||||
@@ -21,7 +24,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);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Bit.Core.Billing.Enums;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.SecretsManager.Queries.Projects.Interfaces;
|
||||
using Bit.Core.SecretsManager.Repositories;
|
||||
using Bit.Core.Utilities;
|
||||
using Bit.Core.Settings;
|
||||
|
||||
namespace Bit.Commercial.Core.SecretsManager.Queries.Projects;
|
||||
|
||||
@@ -11,35 +12,43 @@ public class MaxProjectsQuery : IMaxProjectsQuery
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IProjectRepository _projectRepository;
|
||||
private readonly IGlobalSettings _globalSettings;
|
||||
private readonly IPricingClient _pricingClient;
|
||||
|
||||
public MaxProjectsQuery(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IProjectRepository projectRepository)
|
||||
IProjectRepository projectRepository,
|
||||
IGlobalSettings globalSettings,
|
||||
IPricingClient pricingClient)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_projectRepository = projectRepository;
|
||||
_globalSettings = globalSettings;
|
||||
_pricingClient = pricingClient;
|
||||
}
|
||||
|
||||
public async Task<(short? max, bool? overMax)> GetByOrgIdAsync(Guid organizationId, int projectsToAdd)
|
||||
{
|
||||
// "MaxProjects" only applies to free 2-person organizations, which can't be self-hosted.
|
||||
if (_globalSettings.SelfHosted)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
var org = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
if (org == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var plan = StaticStore.GetPlan(org.PlanType);
|
||||
if (plan?.SecretsManager == null)
|
||||
var plan = await _pricingClient.GetPlan(org.PlanType);
|
||||
|
||||
if (plan is not { SecretsManager: not null, Type: PlanType.Free })
|
||||
{
|
||||
throw new BadRequestException("Existing plan not found.");
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
if (plan.Type == PlanType.Free)
|
||||
{
|
||||
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false));
|
||||
}
|
||||
|
||||
return (null, null);
|
||||
var projects = await _projectRepository.GetProjectCountByOrganizationIdAsync(organizationId);
|
||||
return ((short? max, bool? overMax))(projects + projectsToAdd > plan.SecretsManager.MaxProjects ? (plan.SecretsManager.MaxProjects, true) : (plan.SecretsManager.MaxProjects, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Bit.Commercial.Core.AdminConsole.Providers;
|
||||
using Bit.Commercial.Core.AdminConsole.Services;
|
||||
using Bit.Commercial.Core.Billing;
|
||||
using Bit.Commercial.Core.Billing.Providers.Queries;
|
||||
using Bit.Commercial.Core.Billing.Providers.Services;
|
||||
using Bit.Core.AdminConsole.Providers.Interfaces;
|
||||
using Bit.Core.AdminConsole.Services;
|
||||
using Bit.Core.Billing.Services;
|
||||
using Bit.Core.Billing.Providers.Queries;
|
||||
using Bit.Core.Billing.Providers.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Bit.Commercial.Core.Utilities;
|
||||
@@ -16,5 +18,7 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
|
||||
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
|
||||
services.AddTransient<IProviderBillingService, ProviderBillingService>();
|
||||
services.AddTransient<IBusinessUnitConverter, BusinessUnitConverter>();
|
||||
services.AddTransient<IGetProviderWarningsQuery, GetProviderWarningsQuery>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
public async Task<IEnumerable<ProjectPermissionDetails>> GetManyByOrganizationIdAsync(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
@@ -45,6 +45,19 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyTrashedSecretsByIds(IEnumerable<Guid> ids)
|
||||
{
|
||||
using (var scope = ServiceScopeFactory.CreateScope())
|
||||
{
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
var secrets = await dbContext.Secret
|
||||
.Where(c => ids.Contains(c.Id) && c.DeletedDate != null)
|
||||
.Include(c => c.Projects)
|
||||
.ToListAsync();
|
||||
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByOrganizationIdAsync(
|
||||
Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
{
|
||||
@@ -66,10 +79,14 @@ public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret,
|
||||
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
|
||||
public async Task<IEnumerable<SecretPermissionDetails>> GetManyDetailsByOrganizationIdAsync(
|
||||
Guid organizationId,
|
||||
Guid userId,
|
||||
AccessClientType accessType)
|
||||
{
|
||||
using var scope = ServiceScopeFactory.CreateScope();
|
||||
var dbContext = GetDatabaseContext(scope);
|
||||
|
||||
var query = dbContext.Secret
|
||||
.Include(c => c.Projects)
|
||||
.Where(c => c.OrganizationId == organizationId && c.DeletedDate == null)
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
*
|
||||
!obj/build-output/publish/*
|
||||
!obj/Docker/empty/
|
||||
!entrypoint.sh
|
||||
@@ -1,9 +1,13 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
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 +15,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 +85,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
@@ -24,7 +27,6 @@ public class GroupsController : Controller
|
||||
private readonly IPatchGroupCommand _patchGroupCommand;
|
||||
private readonly IPostGroupCommand _postGroupCommand;
|
||||
private readonly IPutGroupCommand _putGroupCommand;
|
||||
private readonly ILogger<GroupsController> _logger;
|
||||
|
||||
public GroupsController(
|
||||
IGroupRepository groupRepository,
|
||||
@@ -33,8 +35,8 @@ public class GroupsController : Controller
|
||||
IDeleteGroupCommand deleteGroupCommand,
|
||||
IPatchGroupCommand patchGroupCommand,
|
||||
IPostGroupCommand postGroupCommand,
|
||||
IPutGroupCommand putGroupCommand,
|
||||
ILogger<GroupsController> logger)
|
||||
IPutGroupCommand putGroupCommand
|
||||
)
|
||||
{
|
||||
_groupRepository = groupRepository;
|
||||
_organizationRepository = organizationRepository;
|
||||
@@ -43,7 +45,6 @@ public class GroupsController : Controller
|
||||
_patchGroupCommand = patchGroupCommand;
|
||||
_postGroupCommand = postGroupCommand;
|
||||
_putGroupCommand = putGroupCommand;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -97,8 +98,13 @@ public class GroupsController : Controller
|
||||
[HttpPatch("{id}")]
|
||||
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
|
||||
{
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organizationId)
|
||||
{
|
||||
throw new NotFoundException("Group not found.");
|
||||
}
|
||||
|
||||
await _patchGroupCommand.PatchGroupAsync(group, model);
|
||||
return new NoContentResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
using Bit.Scim.Utilities;
|
||||
@@ -17,33 +20,29 @@ 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;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
|
||||
public UsersController(
|
||||
IUserService userService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
public UsersController(IOrganizationUserRepository organizationUserRepository,
|
||||
IGetUsersListQuery getUsersListQuery,
|
||||
IDeleteOrganizationUserCommand deleteOrganizationUserCommand,
|
||||
IRemoveOrganizationUserCommand removeOrganizationUserCommand,
|
||||
IPatchUserCommand patchUserCommand,
|
||||
IPostUserCommand postUserCommand,
|
||||
ILogger<UsersController> logger)
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_getUsersListQuery = getUsersListQuery;
|
||||
_deleteOrganizationUserCommand = deleteOrganizationUserCommand;
|
||||
_removeOrganizationUserCommand = removeOrganizationUserCommand;
|
||||
_patchUserCommand = patchUserCommand;
|
||||
_postUserCommand = postUserCommand;
|
||||
_logger = logger;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
@@ -60,17 +59,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,11 +95,11 @@ public class UsersController : Controller
|
||||
|
||||
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
}
|
||||
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
}
|
||||
|
||||
// Have to get full details object for response model
|
||||
@@ -120,7 +117,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Determine proper runtime value for .NET
|
||||
# We put the value in a file to be read by later layers.
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-musl-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-musl-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-musl-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
# Copy required project files
|
||||
WORKDIR /source
|
||||
COPY . ./
|
||||
|
||||
# Restore project dependencies and tools
|
||||
WORKDIR /source/bitwarden_license/src/Scim
|
||||
RUN . /tmp/rid.txt && dotnet restore -r $RID
|
||||
|
||||
# Build project
|
||||
RUN . /tmp/rid.txt && dotnet publish \
|
||||
-c release \
|
||||
--no-restore \
|
||||
--self-contained \
|
||||
/p:PublishSingleFile=true \
|
||||
-r $RID \
|
||||
-o out
|
||||
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 5000
|
||||
COPY obj/build-output/publish .
|
||||
COPY entrypoint.sh /
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
krb5 \
|
||||
icu-libs \
|
||||
shadow \
|
||||
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
|
||||
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
COPY --from=build /source/bitwarden_license/src/Scim/out /app
|
||||
COPY ./bitwarden_license/src/Scim/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Scim.Groups.Interfaces;
|
||||
|
||||
|
||||
@@ -5,5 +5,5 @@ namespace Bit.Scim.Groups.Interfaces;
|
||||
|
||||
public interface IPatchGroupCommand
|
||||
{
|
||||
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model);
|
||||
Task PatchGroupAsync(Group group, ScimPatchModel model);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
using System.Text.Json;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
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;
|
||||
|
||||
@@ -16,118 +21,137 @@ public class PatchGroupCommand : IPatchGroupCommand
|
||||
private readonly IGroupService _groupService;
|
||||
private readonly IUpdateGroupCommand _updateGroupCommand;
|
||||
private readonly ILogger<PatchGroupCommand> _logger;
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
|
||||
public PatchGroupCommand(
|
||||
IGroupRepository groupRepository,
|
||||
IGroupService groupService,
|
||||
IUpdateGroupCommand updateGroupCommand,
|
||||
ILogger<PatchGroupCommand> logger)
|
||||
ILogger<PatchGroupCommand> logger,
|
||||
IOrganizationRepository organizationRepository)
|
||||
{
|
||||
_groupRepository = groupRepository;
|
||||
_groupService = groupService;
|
||||
_updateGroupCommand = updateGroupCommand;
|
||||
_logger = logger;
|
||||
_organizationRepository = organizationRepository;
|
||||
}
|
||||
|
||||
public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model)
|
||||
public async Task PatchGroupAsync(Group group, ScimPatchModel model)
|
||||
{
|
||||
var group = await _groupRepository.GetByIdAsync(id);
|
||||
if (group == null || group.OrganizationId != organization.Id)
|
||||
{
|
||||
throw new NotFoundException("Group not found.");
|
||||
}
|
||||
|
||||
var operationHandled = false;
|
||||
foreach (var operation in model.Operations)
|
||||
{
|
||||
// Replace operations
|
||||
if (operation.Op?.ToLowerInvariant() == "replace")
|
||||
{
|
||||
// Replace a list of members
|
||||
if (operation.Path?.ToLowerInvariant() == "members")
|
||||
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);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
// Replace group name from path
|
||||
else if (operation.Path?.ToLowerInvariant() == "displayname")
|
||||
|
||||
// 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);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
// Replace group name from value object
|
||||
else if (string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
|
||||
|
||||
// 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);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Add a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
case PatchOps.Add when
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var addId = GetOperationPathId(operation.Path);
|
||||
if (addId.HasValue)
|
||||
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();
|
||||
orgUserIds.Add(addId.Value);
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Remove(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Add a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "add" &&
|
||||
operation.Path?.ToLowerInvariant() == "members")
|
||||
{
|
||||
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
|
||||
foreach (var v in GetOperationValueIds(operation.Value))
|
||||
{
|
||||
orgUserIds.Add(v);
|
||||
}
|
||||
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
|
||||
operationHandled = true;
|
||||
}
|
||||
// Remove a single member
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
!string.IsNullOrWhiteSpace(operation.Path) &&
|
||||
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
|
||||
{
|
||||
var removeId = GetOperationPathId(operation.Path);
|
||||
if (removeId.HasValue)
|
||||
{
|
||||
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
// Remove a list of members
|
||||
else if (operation.Op?.ToLowerInvariant() == "remove" &&
|
||||
operation.Path?.ToLowerInvariant() == "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);
|
||||
operationHandled = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!operationHandled)
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {0} : ",
|
||||
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
|
||||
default:
|
||||
{
|
||||
_logger.LogWarning("Group patch operation not handled: {OperationOp}:{OperationPath}", operation.Op, operation.Path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<Guid> GetOperationValueIds(JsonElement objArray)
|
||||
private async Task AddMembersAsync(Group group, HashSet<Guid> usersToAdd)
|
||||
{
|
||||
var ids = new List<Guid>();
|
||||
// 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))
|
||||
@@ -141,13 +165,9 @@ public class PatchGroupCommand : IPatchGroupCommand
|
||||
return ids;
|
||||
}
|
||||
|
||||
private Guid? GetOperationPathId(string path)
|
||||
private static bool TryGetOperationPathId(string path, out Guid pathId)
|
||||
{
|
||||
// Parse Guid from string like: members[value eq "{GUID}"}]
|
||||
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
|
||||
{
|
||||
return id;
|
||||
}
|
||||
return null;
|
||||
return Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out pathId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Scim.Utilities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
namespace Bit.Scim.Models;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
public abstract class BaseScimModel
|
||||
{
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Scim.Utilities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
|
||||
12
bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs
Normal file
12
bitwarden_license/src/Scim/Models/GetUserQueryParamModel.cs
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Scim.Utilities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Scim.Utilities;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Scim.Utilities;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Text.Json;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Bit.Scim.Models;
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Business;
|
||||
using Bit.Core.Models.Data;
|
||||
using Bit.Core.Utilities;
|
||||
@@ -10,7 +16,8 @@ public class ScimUserRequestModel : BaseScimUserModel
|
||||
{
|
||||
public ScimUserRequestModel()
|
||||
: base(false)
|
||||
{ }
|
||||
{
|
||||
}
|
||||
|
||||
public OrganizationUserInvite ToOrganizationUserInvite(ScimProviderType scimProvider)
|
||||
{
|
||||
@@ -25,6 +32,31 @@ public class ScimUserRequestModel : BaseScimUserModel
|
||||
};
|
||||
}
|
||||
|
||||
public InviteOrganizationUsersRequest ToRequest(
|
||||
ScimProviderType scimProvider,
|
||||
InviteOrganization inviteOrganization,
|
||||
DateTimeOffset performedAt)
|
||||
{
|
||||
var email = EmailForInvite(scimProvider);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !Active)
|
||||
{
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
return new InviteOrganizationUsersRequest(
|
||||
invites:
|
||||
[
|
||||
new OrganizationUserInviteCommandModel(
|
||||
email: email,
|
||||
externalId: ExternalIdForInvite()
|
||||
)
|
||||
],
|
||||
inviteOrganization: inviteOrganization,
|
||||
performedBy: Guid.Empty, // SCIM does not have a user id
|
||||
performedAt: performedAt);
|
||||
}
|
||||
|
||||
private string EmailForInvite(ScimProviderType scimProvider)
|
||||
{
|
||||
var email = PrimaryEmail?.ToLowerInvariant();
|
||||
|
||||
@@ -11,21 +11,8 @@ public class Program
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
||||
{
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
|
||||
if (e.Properties.ContainsKey("RequestPath") &&
|
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
|
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default;
|
||||
}));
|
||||
})
|
||||
.AddSerilogFileLogging()
|
||||
.Build()
|
||||
.Run();
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -91,11 +94,8 @@ public class Startup
|
||||
public void Configure(
|
||||
IApplicationBuilder app,
|
||||
IWebHostEnvironment env,
|
||||
IHostApplicationLifetime appLifetime,
|
||||
GlobalSettings globalSettings)
|
||||
{
|
||||
app.UseSerilog(env, appLifetime, globalSettings);
|
||||
|
||||
// Add general security headers
|
||||
app.UseMiddleware<SecurityHeadersMiddleware>();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
|
||||
@@ -13,22 +16,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 +64,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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Scim.Models;
|
||||
|
||||
namespace Bit.Scim.Users.Interfaces;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.RestoreUser.v1;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Repositories;
|
||||
using Bit.Core.Services;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
|
||||
@@ -9,21 +10,20 @@ namespace Bit.Scim.Users;
|
||||
|
||||
public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
private readonly IUserService _userService;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
|
||||
private readonly ILogger<PatchUserCommand> _logger;
|
||||
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
|
||||
|
||||
public PatchUserCommand(
|
||||
IUserService userService,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
ILogger<PatchUserCommand> logger)
|
||||
public PatchUserCommand(IOrganizationUserRepository organizationUserRepository,
|
||||
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
|
||||
ILogger<PatchUserCommand> logger,
|
||||
IRevokeOrganizationUserCommand revokeOrganizationUserCommand)
|
||||
{
|
||||
_userService = userService;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
|
||||
_logger = logger;
|
||||
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
|
||||
}
|
||||
|
||||
public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model)
|
||||
@@ -74,12 +74,12 @@ public class PatchUserCommand : IPatchUserCommand
|
||||
{
|
||||
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
|
||||
await _restoreOrganizationUserCommand.RestoreUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
return true;
|
||||
}
|
||||
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
|
||||
{
|
||||
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
await _revokeOrganizationUserCommand.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
using Bit.Core.Enums;
|
||||
#nullable enable
|
||||
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Models.Business;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors;
|
||||
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models;
|
||||
using Bit.Core.AdminConsole.Utilities.Commands;
|
||||
using Bit.Core.Billing.Pricing;
|
||||
using Bit.Core.Enums;
|
||||
using Bit.Core.Exceptions;
|
||||
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
|
||||
using Bit.Core.Repositories;
|
||||
@@ -6,34 +16,83 @@ using Bit.Core.Services;
|
||||
using Bit.Scim.Context;
|
||||
using Bit.Scim.Models;
|
||||
using Bit.Scim.Users.Interfaces;
|
||||
using static Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Errors.ErrorMapper;
|
||||
|
||||
namespace Bit.Scim.Users;
|
||||
|
||||
public class PostUserCommand : IPostUserCommand
|
||||
public class PostUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IPaymentService paymentService,
|
||||
IScimContext scimContext,
|
||||
IFeatureService featureService,
|
||||
IInviteOrganizationUsersCommand inviteOrganizationUsersCommand,
|
||||
TimeProvider timeProvider,
|
||||
IPricingClient pricingClient)
|
||||
: IPostUserCommand
|
||||
{
|
||||
private readonly IOrganizationRepository _organizationRepository;
|
||||
private readonly IOrganizationUserRepository _organizationUserRepository;
|
||||
private readonly IOrganizationService _organizationService;
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IScimContext _scimContext;
|
||||
|
||||
public PostUserCommand(
|
||||
IOrganizationRepository organizationRepository,
|
||||
IOrganizationUserRepository organizationUserRepository,
|
||||
IOrganizationService organizationService,
|
||||
IPaymentService paymentService,
|
||||
IScimContext scimContext)
|
||||
public async Task<OrganizationUserUserDetails?> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||
{
|
||||
_organizationRepository = organizationRepository;
|
||||
_organizationUserRepository = organizationUserRepository;
|
||||
_organizationService = organizationService;
|
||||
_paymentService = paymentService;
|
||||
_scimContext = scimContext;
|
||||
if (featureService.IsEnabled(FeatureFlagKeys.ScimInviteUserOptimization) is false)
|
||||
{
|
||||
return await InviteScimOrganizationUserAsync(model, organizationId, scimContext.RequestScimProvider);
|
||||
}
|
||||
|
||||
return await InviteScimOrganizationUserAsync_vNext(model, organizationId, scimContext.RequestScimProvider);
|
||||
}
|
||||
|
||||
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
|
||||
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync_vNext(
|
||||
ScimUserRequestModel model,
|
||||
Guid organizationId,
|
||||
ScimProviderType scimProvider)
|
||||
{
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization is null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var plan = await pricingClient.GetPlanOrThrow(organization.PlanType);
|
||||
|
||||
var request = model.ToRequest(
|
||||
scimProvider: scimProvider,
|
||||
inviteOrganization: new InviteOrganization(organization, plan),
|
||||
performedAt: timeProvider.GetUtcNow());
|
||||
|
||||
var orgUsers = await organizationUserRepository
|
||||
.GetManyDetailsByOrganizationAsync(request.InviteOrganization.OrganizationId);
|
||||
|
||||
if (orgUsers.Any(existingUser =>
|
||||
request.Invites.First().Email.Equals(existingUser.Email, StringComparison.OrdinalIgnoreCase) ||
|
||||
request.Invites.First().ExternalId.Equals(existingUser.ExternalId, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new ConflictException("User already exists.");
|
||||
}
|
||||
|
||||
var result = await inviteOrganizationUsersCommand.InviteScimOrganizationUserAsync(request);
|
||||
|
||||
var invitedOrganizationUserId = result switch
|
||||
{
|
||||
Success<ScimInviteOrganizationUsersResponse> success => success.Value.InvitedUser.Id,
|
||||
Failure<ScimInviteOrganizationUsersResponse> { Error.Message: NoUsersToInviteError.Code } => (Guid?)null,
|
||||
Failure<ScimInviteOrganizationUsersResponse> failure => throw MapToBitException(failure.Error),
|
||||
_ => throw new InvalidOperationException()
|
||||
};
|
||||
|
||||
var organizationUser = invitedOrganizationUserId.HasValue
|
||||
? await organizationUserRepository.GetDetailsByIdAsync(invitedOrganizationUserId.Value)
|
||||
: null;
|
||||
|
||||
return organizationUser;
|
||||
}
|
||||
|
||||
private async Task<OrganizationUserUserDetails?> InviteScimOrganizationUserAsync(
|
||||
ScimUserRequestModel model,
|
||||
Guid organizationId,
|
||||
ScimProviderType scimProvider)
|
||||
{
|
||||
var scimProvider = _scimContext.RequestScimProvider;
|
||||
var invite = model.ToOrganizationUserInvite(scimProvider);
|
||||
|
||||
var email = invite.Emails.Single();
|
||||
@@ -44,7 +103,7 @@ public class PostUserCommand : IPostUserCommand
|
||||
throw new BadRequestException();
|
||||
}
|
||||
|
||||
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUsers = await organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
|
||||
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
|
||||
if (orgUserByEmail != null)
|
||||
{
|
||||
@@ -57,13 +116,21 @@ public class PostUserCommand : IPostUserCommand
|
||||
throw new ConflictException();
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
var hasStandaloneSecretsManager = await _paymentService.HasSecretsManagerStandalone(organization);
|
||||
var organization = await organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
var hasStandaloneSecretsManager = await paymentService.HasSecretsManagerStandalone(organization);
|
||||
invite.AccessSecretsManager = hasStandaloneSecretsManager;
|
||||
|
||||
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, invitingUserId: null, EventSystemUser.SCIM,
|
||||
invite, externalId);
|
||||
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
var invitedOrgUser = await organizationService.InviteUserAsync(organizationId, invitingUserId: null,
|
||||
EventSystemUser.SCIM,
|
||||
invite,
|
||||
externalId);
|
||||
var orgUser = await organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
|
||||
|
||||
return orgUser;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
},
|
||||
"storage": {
|
||||
"connectionString": "UseDevelopmentStorage=true"
|
||||
}
|
||||
},
|
||||
"pricingUri": "https://billingpricing.qa.bitwarden.pw"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"connectionString": "SECRET",
|
||||
"applicationCacheTopicName": "SECRET"
|
||||
},
|
||||
"sentry": {
|
||||
"dsn": "SECRET"
|
||||
},
|
||||
"notificationHub": {
|
||||
"connectionString": "SECRET",
|
||||
"hubName": "SECRET"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
|
||||
# Setup
|
||||
|
||||
@@ -19,25 +19,42 @@ then
|
||||
LGID=65534
|
||||
fi
|
||||
|
||||
# Create user and group
|
||||
if [ "$(id -u)" = "0" ]
|
||||
then
|
||||
# Create user and group
|
||||
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
groupadd -o -g $LGID $GROUPNAME >/dev/null 2>&1 ||
|
||||
groupmod -o -g $LGID $GROUPNAME >/dev/null 2>&1
|
||||
useradd -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1 ||
|
||||
usermod -o -u $LUID -g $GROUPNAME -s /bin/false $USERNAME >/dev/null 2>&1
|
||||
mkhomedir_helper $USERNAME
|
||||
|
||||
# The rest...
|
||||
# The rest...
|
||||
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
chown -R $USERNAME:$GROUPNAME /app
|
||||
mkdir -p /etc/bitwarden/core
|
||||
mkdir -p /etc/bitwarden/logs
|
||||
mkdir -p /etc/bitwarden/ca-certificates
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden
|
||||
|
||||
if [[ $globalSettings__selfHosted == "true" ]]; then
|
||||
cp /etc/bitwarden/ca-certificates/*.crt /usr/local/share/ca-certificates/ >/dev/null 2>&1 \
|
||||
&& update-ca-certificates
|
||||
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
|
||||
chown -R $USERNAME:$GROUPNAME /etc/bitwarden/kerberos
|
||||
fi
|
||||
|
||||
gosu_cmd="gosu $USERNAME:$GROUPNAME"
|
||||
else
|
||||
gosu_cmd=""
|
||||
fi
|
||||
|
||||
exec gosu $USERNAME:$GROUPNAME dotnet /app/Scim.dll
|
||||
if [ -f "/etc/bitwarden/kerberos/bitwarden.keytab" ] && [ -f "/etc/bitwarden/kerberos/krb5.conf" ]; then
|
||||
cp -f /etc/bitwarden/kerberos/krb5.conf /etc/krb5.conf
|
||||
$gosu_cmd kinit $globalSettings__kerberosUser -k -t /etc/bitwarden/kerberos/bitwarden.keytab
|
||||
fi
|
||||
|
||||
if [ "$globalSettings__selfHosted" = "true" ]; then
|
||||
if [ -z "$globalSettings__identityServer__certificateLocation" ]; then
|
||||
export globalSettings__identityServer__certificateLocation=/etc/bitwarden/identity/identity.pfx
|
||||
fi
|
||||
fi
|
||||
|
||||
exec $gosu_cmd /app/Scim
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using Bit.Core;
|
||||
using Bit.Core.AdminConsole.Entities;
|
||||
using Bit.Core.AdminConsole.Enums;
|
||||
using Bit.Core.AdminConsole.Repositories;
|
||||
using Bit.Core.Auth.Entities;
|
||||
@@ -19,10 +20,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;
|
||||
@@ -53,6 +54,7 @@ public class AccountController : Controller
|
||||
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
|
||||
private readonly IOrganizationDomainRepository _organizationDomainRepository;
|
||||
private readonly IRegisterUserCommand _registerUserCommand;
|
||||
private readonly IFeatureService _featureService;
|
||||
|
||||
public AccountController(
|
||||
IAuthenticationSchemeProvider schemeProvider,
|
||||
@@ -73,7 +75,8 @@ public class AccountController : Controller
|
||||
Core.Services.IEventService eventService,
|
||||
IDataProtectorTokenFactory<SsoTokenable> dataProtector,
|
||||
IOrganizationDomainRepository organizationDomainRepository,
|
||||
IRegisterUserCommand registerUserCommand)
|
||||
IRegisterUserCommand registerUserCommand,
|
||||
IFeatureService featureService)
|
||||
{
|
||||
_schemeProvider = schemeProvider;
|
||||
_clientStore = clientStore;
|
||||
@@ -94,46 +97,43 @@ public class AccountController : Controller
|
||||
_dataProtector = dataProtector;
|
||||
_organizationDomainRepository = organizationDomainRepository;
|
||||
_registerUserCommand = registerUserCommand;
|
||||
_featureService = featureService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> PreValidate(string domainHint)
|
||||
public async Task<IActionResult> PreValidateAsync(string domainHint)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate domain_hint provided
|
||||
if (string.IsNullOrWhiteSpace(domainHint))
|
||||
{
|
||||
return InvalidJson("NoOrganizationIdentifierProvidedError");
|
||||
_logger.LogError(new ArgumentException("domainHint is required."), "domainHint not specified.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Validate organization exists from domain_hint
|
||||
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
|
||||
if (organization == null)
|
||||
if (organization is not { UseSso: true })
|
||||
{
|
||||
return InvalidJson("OrganizationNotFoundByIdentifierError");
|
||||
}
|
||||
if (!organization.UseSso)
|
||||
{
|
||||
return InvalidJson("SsoNotAllowedForOrganizationError");
|
||||
_logger.LogError("Organization not configured to use SSO.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Validate SsoConfig exists and is Enabled
|
||||
var ssoConfig = await _ssoConfigRepository.GetByIdentifierAsync(domainHint);
|
||||
if (ssoConfig == null)
|
||||
if (ssoConfig is not { Enabled: true })
|
||||
{
|
||||
return InvalidJson("SsoConfigurationNotFoundForOrganizationError");
|
||||
}
|
||||
if (!ssoConfig.Enabled)
|
||||
{
|
||||
return InvalidJson("SsoNotEnabledForOrganizationError");
|
||||
_logger.LogError("SsoConfig not enabled.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Validate Authentication Scheme exists and is loaded (cache)
|
||||
var scheme = await _schemeProvider.GetSchemeAsync(organization.Id.ToString());
|
||||
if (scheme == null || !(scheme is IDynamicAuthenticationScheme dynamicScheme))
|
||||
if (scheme is not IDynamicAuthenticationScheme dynamicScheme)
|
||||
{
|
||||
return InvalidJson("NoSchemeOrHandlerForSsoConfigurationFoundError");
|
||||
_logger.LogError("Invalid authentication scheme for organization.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
// Run scheme validation
|
||||
@@ -143,13 +143,8 @@ public class AccountController : Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var translatedException = _i18nService.GetLocalizedHtmlString(ex.Message);
|
||||
var errorKey = "InvalidSchemeConfigurationError";
|
||||
if (!translatedException.ResourceNotFound)
|
||||
{
|
||||
errorKey = ex.Message;
|
||||
}
|
||||
return InvalidJson(errorKey, translatedException.ResourceNotFound ? ex : null);
|
||||
_logger.LogError(ex, "An error occurred while validating SSO dynamic scheme.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
|
||||
var tokenable = new SsoTokenable(organization, _globalSettings.Sso.SsoTokenLifetimeInSeconds);
|
||||
@@ -159,15 +154,18 @@ public class AccountController : Controller
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return InvalidJson("PreValidationError", ex);
|
||||
_logger.LogError(ex, "An error occurred during SSO prevalidation.");
|
||||
return InvalidJson("SsoInvalidIdentifierError");
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Login(string returnUrl)
|
||||
public async Task<IActionResult> LoginAsync(string returnUrl)
|
||||
{
|
||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
if (!context.Parameters.AllKeys.Contains("domain_hint") ||
|
||||
string.IsNullOrWhiteSpace(context.Parameters["domain_hint"]))
|
||||
{
|
||||
@@ -183,6 +181,7 @@ public class AccountController : Controller
|
||||
|
||||
var domainHint = context.Parameters["domain_hint"];
|
||||
var organization = await _organizationRepository.GetByIdentifierAsync(domainHint);
|
||||
#nullable restore
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
@@ -239,32 +238,73 @@ public class AccountController : Controller
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> ExternalCallback()
|
||||
{
|
||||
// Feature flag (PM-24579): Prevent SSO on existing non-compliant users.
|
||||
var preventOrgUserLoginIfStatusInvalid =
|
||||
_featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers);
|
||||
|
||||
// Read external identity from the temporary cookie
|
||||
var result = await HttpContext.AuthenticateAsync(
|
||||
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||
if (result?.Succeeded != true)
|
||||
|
||||
if (preventOrgUserLoginIfStatusInvalid)
|
||||
{
|
||||
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (result?.Succeeded != true)
|
||||
{
|
||||
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
|
||||
}
|
||||
}
|
||||
|
||||
// Debugging
|
||||
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}");
|
||||
_logger.LogDebug("External claims: {@claims}", externalClaims);
|
||||
// See if the user has logged in with this SSO provider before and has already been provisioned.
|
||||
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
|
||||
var (possibleSsoLinkedUser, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
||||
|
||||
// Lookup our user and external provider info
|
||||
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
|
||||
if (user == null)
|
||||
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
|
||||
Organization? organization = null;
|
||||
OrganizationUser? orgUser = null;
|
||||
|
||||
// The user has not authenticated with this SSO provider before.
|
||||
// They could have an existing Bitwarden account in the User table though.
|
||||
if (possibleSsoLinkedUser == null)
|
||||
{
|
||||
// This might be where you might initiate a custom workflow for user registration
|
||||
// in this sample we don't show how that would be done, as our sample implementation
|
||||
// simply auto-provisions new external user
|
||||
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ?
|
||||
result.Properties.Items["user_identifier"] : null;
|
||||
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData);
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
|
||||
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
|
||||
? result.Properties.Items["user_identifier"]
|
||||
: null;
|
||||
|
||||
var (resolvedUser, foundOrganization, foundOrCreatedOrgUser) =
|
||||
await CreateUserAndOrgUserConditionallyAsync(
|
||||
provider,
|
||||
providerUserId,
|
||||
claims,
|
||||
userIdentifier,
|
||||
ssoConfigData);
|
||||
#nullable restore
|
||||
|
||||
possibleSsoLinkedUser = resolvedUser;
|
||||
|
||||
if (preventOrgUserLoginIfStatusInvalid)
|
||||
{
|
||||
organization = foundOrganization;
|
||||
orgUser = foundOrCreatedOrgUser;
|
||||
}
|
||||
}
|
||||
|
||||
if (user != null)
|
||||
if (preventOrgUserLoginIfStatusInvalid)
|
||||
{
|
||||
User resolvedSsoLinkedUser = possibleSsoLinkedUser
|
||||
?? throw new Exception(_i18nService.T("UserShouldBeFound"));
|
||||
|
||||
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, resolvedSsoLinkedUser);
|
||||
|
||||
// This allows us to collect any additional claims or properties
|
||||
// for the specific protocols used and store them in the local auth cookie.
|
||||
// this is typically used to store data needed for signout from those protocols.
|
||||
@@ -277,19 +317,52 @@ public class AccountController : Controller
|
||||
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
|
||||
|
||||
// Issue authentication cookie for user
|
||||
await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString())
|
||||
await HttpContext.SignInAsync(
|
||||
new IdentityServerUser(resolvedSsoLinkedUser.Id.ToString())
|
||||
{
|
||||
DisplayName = resolvedSsoLinkedUser.Email,
|
||||
IdentityProvider = provider,
|
||||
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||
}, localSignInProps);
|
||||
}
|
||||
else
|
||||
{
|
||||
// PM-24579: remove this else block with feature flag removal.
|
||||
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
|
||||
// Either way, we have associated the SSO login with a Bitwarden user.
|
||||
// We will now sign the Bitwarden user in.
|
||||
if (possibleSsoLinkedUser != null)
|
||||
{
|
||||
DisplayName = user.Email,
|
||||
IdentityProvider = provider,
|
||||
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||
}, localSignInProps);
|
||||
// This allows us to collect any additional claims or properties
|
||||
// for the specific protocols used and store them in the local auth cookie.
|
||||
// this is typically used to store data needed for signout from those protocols.
|
||||
var additionalLocalClaims = new List<Claim>();
|
||||
var localSignInProps = new AuthenticationProperties
|
||||
{
|
||||
IsPersistent = true,
|
||||
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
|
||||
};
|
||||
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
|
||||
|
||||
// Issue authentication cookie for user
|
||||
await HttpContext.SignInAsync(
|
||||
new IdentityServerUser(possibleSsoLinkedUser.Id.ToString())
|
||||
{
|
||||
DisplayName = possibleSsoLinkedUser.Email,
|
||||
IdentityProvider = provider,
|
||||
AdditionalClaims = additionalLocalClaims.ToArray()
|
||||
}, localSignInProps);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete temporary cookie used during external authentication
|
||||
await HttpContext.SignOutAsync(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
|
||||
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
// Retrieve return URL
|
||||
var returnUrl = result.Properties.Items["return_url"] ?? "~/";
|
||||
#nullable restore
|
||||
|
||||
// Check if external login is in the context of an OIDC request
|
||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||
@@ -308,8 +381,10 @@ public class AccountController : Controller
|
||||
return Redirect(returnUrl);
|
||||
}
|
||||
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Logout(string logoutId)
|
||||
public async Task<IActionResult> LogoutAsync(string logoutId)
|
||||
{
|
||||
// Build a model so the logged out page knows what to display
|
||||
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
|
||||
@@ -332,6 +407,7 @@ public class AccountController : Controller
|
||||
// This triggers a redirect to the external provider for sign-out
|
||||
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
|
||||
}
|
||||
|
||||
if (redirectUri != null)
|
||||
{
|
||||
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
|
||||
@@ -341,10 +417,22 @@ public class AccountController : Controller
|
||||
return Redirect("~/");
|
||||
}
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)>
|
||||
FindUserFromExternalProviderAsync(AuthenticateResult result)
|
||||
/// <summary>
|
||||
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
|
||||
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
|
||||
/// </summary>
|
||||
private async Task<(
|
||||
User? possibleSsoUser,
|
||||
string provider,
|
||||
string providerUserId,
|
||||
IEnumerable<Claim> claims,
|
||||
SsoConfigurationData config
|
||||
)> FindUserFromExternalProviderAsync(AuthenticateResult result)
|
||||
{
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
var provider = result.Properties.Items["scheme"];
|
||||
var orgId = new Guid(provider);
|
||||
var ssoConfig = await _ssoConfigRepository.GetByOrganizationIdAsync(orgId);
|
||||
@@ -369,9 +457,10 @@ public class AccountController : Controller
|
||||
// Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute
|
||||
// for the user identifier.
|
||||
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
|
||||
&& (c.Properties == null
|
||||
|| !c.Properties.ContainsKey(SamlPropertyKeys.ClaimFormat)
|
||||
|| c.Properties[SamlPropertyKeys.ClaimFormat] != SamlNameIdFormats.Transient);
|
||||
&& (c.Properties == null
|
||||
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat,
|
||||
out var claimFormat)
|
||||
|| claimFormat != SamlNameIdFormats.Transient);
|
||||
|
||||
// Try to determine the unique id of the external user (issued by the provider)
|
||||
// the most common claim type for that are the sub claim and the NameIdentifier
|
||||
@@ -386,6 +475,7 @@ public class AccountController : Controller
|
||||
externalUser.FindFirst("upn") ??
|
||||
externalUser.FindFirst("eppn") ??
|
||||
throw new Exception(_i18nService.T("UnknownUserId"));
|
||||
#nullable restore
|
||||
|
||||
// Remove the user id claim so we don't include it as an extra claim if/when we provision the user
|
||||
var claims = externalUser.Claims.ToList();
|
||||
@@ -394,114 +484,123 @@ public class AccountController : Controller
|
||||
// find external user
|
||||
var providerUserId = userIdClaim.Value;
|
||||
|
||||
var user = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
|
||||
var possibleSsoUser = await _userRepository.GetBySsoUserAsync(providerUserId, orgId);
|
||||
|
||||
return (user, provider, providerUserId, claims, ssoConfigData);
|
||||
return (possibleSsoUser, provider, providerUserId, claims, ssoConfigData);
|
||||
}
|
||||
|
||||
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId,
|
||||
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config)
|
||||
/// <summary>
|
||||
/// This function seeks to set up the org user record or create a new user record based on the conditions
|
||||
/// below.
|
||||
///
|
||||
/// This handles three different scenarios:
|
||||
/// 1. Creating an SsoUser link for an existing User and OrganizationUser
|
||||
/// - User is a member of the organization, but hasn't authenticated with the org's SSO provider before.
|
||||
/// 2. Creating a new User and a new OrganizationUser, then establishing an SsoUser link
|
||||
/// - User is joining the organization through JIT provisioning, without a pending invitation
|
||||
/// 3. Creating a new User for an existing OrganizationUser (created by invitation), then establishing an SsoUser link
|
||||
/// - User is signing in with a pending invitation.
|
||||
/// </summary>
|
||||
/// <param name="provider">The external identity provider.</param>
|
||||
/// <param name="providerUserId">The external identity provider's user identifier.</param>
|
||||
/// <param name="claims">The claims from the external IdP.</param>
|
||||
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
|
||||
/// <param name="ssoConfigData">The SSO configuration for the organization.</param>
|
||||
/// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
|
||||
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
|
||||
private async Task<(User resolvedUser, Organization foundOrganization, OrganizationUser foundOrgUser)> CreateUserAndOrgUserConditionallyAsync(
|
||||
string provider,
|
||||
string providerUserId,
|
||||
IEnumerable<Claim> claims,
|
||||
string userIdentifier,
|
||||
SsoConfigurationData ssoConfigData
|
||||
)
|
||||
{
|
||||
var name = GetName(claims, config.GetAdditionalNameClaimTypes());
|
||||
var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes());
|
||||
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
|
||||
{
|
||||
email = providerUserId;
|
||||
}
|
||||
// Try to get the email from the claims as we don't know if we have a user record yet.
|
||||
var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
|
||||
var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
|
||||
|
||||
if (!Guid.TryParse(provider, out var orgId))
|
||||
{
|
||||
// TODO: support non-org (server-wide) SSO in the future?
|
||||
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
|
||||
}
|
||||
|
||||
User existingUser = null;
|
||||
User? possibleExistingUser;
|
||||
if (string.IsNullOrWhiteSpace(userIdentifier))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
||||
}
|
||||
existingUser = await _userRepository.GetByEmailAsync(email);
|
||||
|
||||
possibleExistingUser = await _userRepository.GetByEmailAsync(email);
|
||||
}
|
||||
else
|
||||
{
|
||||
var split = userIdentifier.Split(",");
|
||||
if (split.Length < 2)
|
||||
{
|
||||
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
|
||||
}
|
||||
var userId = split[0];
|
||||
var token = split[1];
|
||||
|
||||
var tokenOptions = new TokenOptions();
|
||||
|
||||
var claimedUser = await _userService.GetUserByIdAsync(userId);
|
||||
if (claimedUser != null)
|
||||
{
|
||||
var tokenIsValid = await _userManager.VerifyUserTokenAsync(
|
||||
claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
|
||||
if (tokenIsValid)
|
||||
{
|
||||
existingUser = claimedUser;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
|
||||
}
|
||||
}
|
||||
possibleExistingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
|
||||
}
|
||||
|
||||
OrganizationUser orgUser = null;
|
||||
var organization = await _organizationRepository.GetByIdAsync(orgId);
|
||||
if (organization == null)
|
||||
{
|
||||
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId));
|
||||
}
|
||||
// Find the org (we error if we can't find an org because no org is not valid)
|
||||
var organization = await GetOrganizationByProviderAsync(provider);
|
||||
|
||||
// Try to find OrgUser via existing User Id (accepted/confirmed user)
|
||||
if (existingUser != null)
|
||||
{
|
||||
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id);
|
||||
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId);
|
||||
}
|
||||
// Try to find an org user (null org user possible and valid here)
|
||||
var possibleOrgUser = await GetOrganizationUserByUserAndOrgIdOrEmailAsync(possibleExistingUser, organization.Id, email);
|
||||
|
||||
// If no Org User found by Existing User Id - search all organization users via email
|
||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email);
|
||||
|
||||
// All Existing User flows handled below
|
||||
if (existingUser != null)
|
||||
//----------------------------------------------------
|
||||
// Scenario 1: We've found the user in the User table
|
||||
//----------------------------------------------------
|
||||
if (possibleExistingUser != null)
|
||||
{
|
||||
if (existingUser.UsesKeyConnector &&
|
||||
(orgUser == null || orgUser.Status == OrganizationUserStatusType.Invited))
|
||||
User guaranteedExistingUser = possibleExistingUser;
|
||||
|
||||
if (guaranteedExistingUser.UsesKeyConnector &&
|
||||
(possibleOrgUser == null || possibleOrgUser.Status == OrganizationUserStatusType.Invited))
|
||||
{
|
||||
throw new Exception(_i18nService.T("UserAlreadyExistsKeyConnector"));
|
||||
}
|
||||
|
||||
if (orgUser == null)
|
||||
OrganizationUser guaranteedOrgUser = possibleOrgUser ?? throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
||||
|
||||
/*
|
||||
* ----------------------------------------------------
|
||||
* Critical Code Check Here
|
||||
*
|
||||
* We want to ensure a user is not in the invited state
|
||||
* explicitly. User's in the invited state should not
|
||||
* be able to authenticate via SSO.
|
||||
*
|
||||
* See internal doc called "Added Context for SSO Login
|
||||
* Flows" for further details.
|
||||
* ----------------------------------------------------
|
||||
*/
|
||||
if (guaranteedOrgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
// Org User is not created - no invite has been sent
|
||||
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
|
||||
// Org User is invited – must accept via email first
|
||||
throw new Exception(
|
||||
_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
|
||||
}
|
||||
|
||||
if (orgUser.Status == OrganizationUserStatusType.Invited)
|
||||
{
|
||||
// Org User is invited - they must manually accept the invite via email and authenticate with MP
|
||||
// This allows us to enroll them in MP reset if required
|
||||
throw new Exception(_i18nService.T("AcceptInviteBeforeUsingSSO", organization.DisplayName()));
|
||||
}
|
||||
// If the user already exists in Bitwarden, we require that the user already be in the org,
|
||||
// and that they are either Accepted or Confirmed.
|
||||
EnforceAllowedOrgUserStatus(
|
||||
guaranteedOrgUser.Status,
|
||||
allowedStatuses: [
|
||||
OrganizationUserStatusType.Accepted,
|
||||
OrganizationUserStatusType.Confirmed
|
||||
],
|
||||
organization.DisplayName());
|
||||
|
||||
// Accepted or Confirmed - create SSO link and return;
|
||||
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser);
|
||||
return existingUser;
|
||||
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not
|
||||
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
|
||||
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
|
||||
// with authentication.
|
||||
await CreateSsoUserRecordAsync(providerUserId, guaranteedExistingUser.Id, organization.Id, guaranteedOrgUser);
|
||||
|
||||
return (guaranteedExistingUser, organization, guaranteedOrgUser);
|
||||
}
|
||||
|
||||
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
|
||||
if (orgUser == null && organization.Seats.HasValue)
|
||||
if (possibleOrgUser == null && organization.Seats.HasValue)
|
||||
{
|
||||
var occupiedSeats = await _organizationUserRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var occupiedSeats =
|
||||
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
|
||||
var initialSeatCount = organization.Seats.Value;
|
||||
var availableSeats = initialSeatCount - occupiedSeats;
|
||||
var availableSeats = initialSeatCount - occupiedSeats.Total;
|
||||
if (availableSeats < 1)
|
||||
{
|
||||
try
|
||||
@@ -517,8 +616,10 @@ public class AccountController : Controller
|
||||
{
|
||||
if (organization.Seats.Value != initialSeatCount)
|
||||
{
|
||||
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value);
|
||||
await _organizationService.AdjustSeatsAsync(organization.Id,
|
||||
initialSeatCount - organization.Seats.Value);
|
||||
}
|
||||
|
||||
_logger.LogInformation(e, "SSO auto provisioning failed");
|
||||
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
|
||||
}
|
||||
@@ -526,65 +627,257 @@ public class AccountController : Controller
|
||||
}
|
||||
|
||||
// If the email domain is verified, we can mark the email as verified
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
throw new Exception(_i18nService.T("CannotFindEmailClaim"));
|
||||
}
|
||||
|
||||
var emailVerified = false;
|
||||
var emailDomain = CoreHelpers.GetEmailDomain(email);
|
||||
if (!string.IsNullOrWhiteSpace(emailDomain))
|
||||
{
|
||||
var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain);
|
||||
var organizationDomain =
|
||||
await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain);
|
||||
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
|
||||
}
|
||||
|
||||
// Create user record - all existing user flows are handled above
|
||||
var user = new User
|
||||
//--------------------------------------------------
|
||||
// Scenarios 2 and 3: We need to register a new user
|
||||
//--------------------------------------------------
|
||||
var newUser = new User
|
||||
{
|
||||
Name = name,
|
||||
Email = email,
|
||||
EmailVerified = emailVerified,
|
||||
ApiKey = CoreHelpers.SecureRandomString(30)
|
||||
};
|
||||
await _registerUserCommand.RegisterUser(user);
|
||||
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
/*
|
||||
The feature flag is checked here so that we can send the new MJML welcome email templates.
|
||||
The other organization invites flows have an OrganizationUser allowing the RegisterUserCommand the ability
|
||||
to fetch the Organization. The old method RegisterUser(User) here does not have that context, so we need
|
||||
to use a new method RegisterSSOAutoProvisionedUserAsync(User, Organization) to send the correct email.
|
||||
[PM-28057]: Prefer RegisterSSOAutoProvisionedUserAsync for SSO auto-provisioned users.
|
||||
TODO: Remove Feature flag: PM-28221
|
||||
*/
|
||||
if (_featureService.IsEnabled(FeatureFlagKeys.MjmlWelcomeEmailTemplates))
|
||||
{
|
||||
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object> { ["Email"] = user.Email.ToLowerInvariant() },
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
await _userService.UpdateTwoFactorProviderAsync(user, TwoFactorProviderType.Email);
|
||||
}
|
||||
|
||||
// Create Org User if null or else update existing Org User
|
||||
if (orgUser == null)
|
||||
{
|
||||
orgUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = orgId,
|
||||
UserId = user.Id,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited
|
||||
};
|
||||
await _organizationUserRepository.CreateAsync(orgUser);
|
||||
await _registerUserCommand.RegisterSSOAutoProvisionedUserAsync(newUser, organization);
|
||||
}
|
||||
else
|
||||
{
|
||||
orgUser.UserId = user.Id;
|
||||
await _organizationUserRepository.ReplaceAsync(orgUser);
|
||||
await _registerUserCommand.RegisterUser(newUser);
|
||||
}
|
||||
|
||||
// Create sso user record
|
||||
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser);
|
||||
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
|
||||
var twoFactorPolicy =
|
||||
await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
|
||||
if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
|
||||
{
|
||||
newUser.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
|
||||
{
|
||||
[TwoFactorProviderType.Email] = new TwoFactorProvider
|
||||
{
|
||||
MetaData = new Dictionary<string, object> { ["Email"] = newUser.Email.ToLowerInvariant() },
|
||||
Enabled = true
|
||||
}
|
||||
});
|
||||
await _userService.UpdateTwoFactorProviderAsync(newUser, TwoFactorProviderType.Email);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// Scenario 2: We also need to create an OrganizationUser
|
||||
// This means that an invitation was not sent for this user and we
|
||||
// need to establish their invited status now.
|
||||
//-----------------------------------------------------------------
|
||||
if (possibleOrgUser == null)
|
||||
{
|
||||
possibleOrgUser = new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = newUser.Id,
|
||||
Type = OrganizationUserType.User,
|
||||
Status = OrganizationUserStatusType.Invited
|
||||
};
|
||||
await _organizationUserRepository.CreateAsync(possibleOrgUser);
|
||||
}
|
||||
|
||||
//-----------------------------------------------------------------
|
||||
// Scenario 3: There is already an existing OrganizationUser
|
||||
// That was established through an invitation. We just need to
|
||||
// update the UserId now that we have created a User record.
|
||||
//-----------------------------------------------------------------
|
||||
else
|
||||
{
|
||||
possibleOrgUser.UserId = newUser.Id;
|
||||
await _organizationUserRepository.ReplaceAsync(possibleOrgUser);
|
||||
}
|
||||
|
||||
// Create the SsoUser record to link the user to the SSO provider.
|
||||
await CreateSsoUserRecordAsync(providerUserId, newUser.Id, organization.Id, possibleOrgUser);
|
||||
|
||||
return (newUser, organization, possibleOrgUser);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an organization user is allowed to log in via SSO and blocks invalid statuses.
|
||||
/// Lazily resolves the organization and organization user if not provided.
|
||||
/// </summary>
|
||||
/// <param name="organization">The target organization; if null, resolved from provider.</param>
|
||||
/// <param name="provider">The SSO scheme provider value (organization id as a GUID string).</param>
|
||||
/// <param name="orgUser">The organization-user record; if null, looked up by user/org or user email for invited users.</param>
|
||||
/// <param name="user">The user attempting to sign in (existing or newly provisioned).</param>
|
||||
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
|
||||
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
|
||||
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
|
||||
Organization? organization,
|
||||
string provider,
|
||||
OrganizationUser? orgUser,
|
||||
User user)
|
||||
{
|
||||
// Lazily get organization if not already known
|
||||
organization ??= await GetOrganizationByProviderAsync(provider);
|
||||
|
||||
// Lazily get the org user if not already known
|
||||
orgUser ??= await GetOrganizationUserByUserAndOrgIdOrEmailAsync(
|
||||
user,
|
||||
organization.Id,
|
||||
user.Email);
|
||||
|
||||
if (orgUser != null)
|
||||
{
|
||||
// Invited is allowed at this point because we know the user is trying to accept an org invite.
|
||||
EnforceAllowedOrgUserStatus(
|
||||
orgUser.Status,
|
||||
allowedStatuses: [
|
||||
OrganizationUserStatusType.Invited,
|
||||
OrganizationUserStatusType.Accepted,
|
||||
OrganizationUserStatusType.Confirmed,
|
||||
],
|
||||
organization.DisplayName());
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception(_i18nService.T("CouldNotFindOrganizationUser", user.Id, organization.Id));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<User?> GetUserFromManualLinkingDataAsync(string userIdentifier)
|
||||
{
|
||||
User? user = null;
|
||||
var split = userIdentifier.Split(",");
|
||||
if (split.Length < 2)
|
||||
{
|
||||
throw new Exception(_i18nService.T("InvalidUserIdentifier"));
|
||||
}
|
||||
|
||||
var userId = split[0];
|
||||
var token = split[1];
|
||||
|
||||
var tokenOptions = new TokenOptions();
|
||||
|
||||
var claimedUser = await _userService.GetUserByIdAsync(userId);
|
||||
if (claimedUser != null)
|
||||
{
|
||||
var tokenIsValid = await _userManager.VerifyUserTokenAsync(
|
||||
claimedUser, tokenOptions.PasswordResetTokenProvider, TokenPurposes.LinkSso, token);
|
||||
if (tokenIsValid)
|
||||
{
|
||||
user = claimedUser;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
|
||||
/// <summary>
|
||||
/// Tries to get the organization by the provider which is org id for us as we use the scheme
|
||||
/// to identify organizations - not identity providers.
|
||||
/// </summary>
|
||||
/// <param name="provider">Org id string from SSO scheme property</param>
|
||||
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
|
||||
private async Task<Organization> GetOrganizationByProviderAsync(string provider)
|
||||
{
|
||||
if (!Guid.TryParse(provider, out var organizationId))
|
||||
{
|
||||
// TODO: support non-org (server-wide) SSO in the future?
|
||||
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
|
||||
}
|
||||
|
||||
var organization = await _organizationRepository.GetByIdAsync(organizationId);
|
||||
|
||||
if (organization == null)
|
||||
{
|
||||
throw new Exception(_i18nService.T("CouldNotFindOrganization", organizationId));
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get an <see cref="OrganizationUser"/> for a given organization
|
||||
/// by first checking for an existing user relationship, and if none is found,
|
||||
/// by looking up an invited user via their email address.
|
||||
/// </summary>
|
||||
/// <param name="user">The existing user entity to be looked up in OrganizationUsers table.</param>
|
||||
/// <param name="organizationId">Organization id from the provider data.</param>
|
||||
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
|
||||
/// table yet.</param>
|
||||
private async Task<OrganizationUser?> GetOrganizationUserByUserAndOrgIdOrEmailAsync(
|
||||
User? user,
|
||||
Guid organizationId,
|
||||
string? email)
|
||||
{
|
||||
OrganizationUser? orgUser = null;
|
||||
|
||||
// Try to find OrgUser via existing User Id.
|
||||
// This covers any OrganizationUser state after they have accepted an invite.
|
||||
if (user != null)
|
||||
{
|
||||
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
|
||||
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId);
|
||||
}
|
||||
|
||||
// If no Org User found by Existing User Id - search all the organization's users via email.
|
||||
// This covers users who are Invited but haven't accepted their invite yet.
|
||||
if (email != null)
|
||||
{
|
||||
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
|
||||
}
|
||||
|
||||
return orgUser;
|
||||
}
|
||||
|
||||
private void EnforceAllowedOrgUserStatus(
|
||||
OrganizationUserStatusType statusToCheckAgainst,
|
||||
OrganizationUserStatusType[] allowedStatuses,
|
||||
string organizationDisplayNameForLogging)
|
||||
{
|
||||
// if this status is one of the allowed ones, just return
|
||||
if (allowedStatuses.Contains(statusToCheckAgainst))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// otherwise throw the appropriate exception
|
||||
switch (statusToCheckAgainst)
|
||||
{
|
||||
case OrganizationUserStatusType.Revoked:
|
||||
// Revoked users may not be (auto)‑provisioned
|
||||
throw new Exception(
|
||||
_i18nService.T("OrganizationUserAccessRevoked", organizationDisplayNameForLogging));
|
||||
default:
|
||||
// anything else is “unknown”
|
||||
throw new Exception(
|
||||
_i18nService.T("OrganizationUserUnknownStatus", organizationDisplayNameForLogging));
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult InvalidJson(string errorMessageKey, Exception? ex = null)
|
||||
{
|
||||
Response.StatusCode = ex == null ? 400 : 500;
|
||||
return Json(new ErrorResponseModel(_i18nService.T(errorMessageKey))
|
||||
@@ -595,13 +888,13 @@ public class AccountController : Controller
|
||||
});
|
||||
}
|
||||
|
||||
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
||||
private string? TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
||||
{
|
||||
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
|
||||
|
||||
var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
|
||||
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
|
||||
SamlClaimTypes.Email, "mail", "emailaddress");
|
||||
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
|
||||
SamlClaimTypes.Email, "mail", "emailaddress");
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return email;
|
||||
@@ -617,13 +910,15 @@ public class AccountController : Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
private string GetName(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
|
||||
{
|
||||
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
|
||||
|
||||
var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
|
||||
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
|
||||
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
|
||||
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
|
||||
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
|
||||
if (!string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
@@ -640,8 +935,10 @@ public class AccountController : Controller
|
||||
|
||||
return null;
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser)
|
||||
private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
|
||||
OrganizationUser orgUser)
|
||||
{
|
||||
// Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
|
||||
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
|
||||
@@ -656,15 +953,12 @@ public class AccountController : Controller
|
||||
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
|
||||
}
|
||||
|
||||
var ssoUser = new SsoUser
|
||||
{
|
||||
ExternalId = providerUserId,
|
||||
UserId = userId,
|
||||
OrganizationId = orgId,
|
||||
};
|
||||
var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, };
|
||||
await _ssoUserRepository.CreateAsync(ssoUser);
|
||||
}
|
||||
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
private void ProcessLoginCallback(AuthenticateResult externalResult,
|
||||
List<Claim> localClaims, AuthenticationProperties localSignInProps)
|
||||
{
|
||||
@@ -685,18 +979,6 @@ public class AccountController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetProviderAsync(string returnUrl)
|
||||
{
|
||||
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
|
||||
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
|
||||
{
|
||||
return context.IdP;
|
||||
}
|
||||
var schemes = await _schemeProvider.GetAllSchemesAsync();
|
||||
var providers = schemes.Select(x => x.Name).ToList();
|
||||
return providers.FirstOrDefault();
|
||||
}
|
||||
|
||||
private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
|
||||
{
|
||||
// Get context information (client name, post logout redirect URI and iframe for federated signout)
|
||||
@@ -727,10 +1009,31 @@ public class AccountController : Controller
|
||||
|
||||
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
/**
|
||||
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
|
||||
* the claims email extraction returns null.
|
||||
*/
|
||||
private string? TryGetEmailAddress(
|
||||
IEnumerable<Claim> claims,
|
||||
SsoConfigurationData config,
|
||||
string providerUserId)
|
||||
{
|
||||
var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());
|
||||
|
||||
// If email isn't populated from claims and providerUserId has @, assume it is the email.
|
||||
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
|
||||
{
|
||||
email = providerUserId;
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
public bool IsNativeClient(DIM.AuthorizationRequest context)
|
||||
{
|
||||
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
|
||||
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
|
||||
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Diagnostics;
|
||||
using Bit.Sso.Models;
|
||||
using Duende.IdentityServer.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0
|
||||
###############################################
|
||||
# Build stage #
|
||||
###############################################
|
||||
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-alpine3.21 AS build
|
||||
|
||||
# Docker buildx supplies the value for this arg
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# Determine proper runtime value for .NET
|
||||
# We put the value in a file to be read by later layers.
|
||||
RUN if [ "$TARGETPLATFORM" = "linux/amd64" ]; then \
|
||||
RID=linux-musl-x64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then \
|
||||
RID=linux-musl-arm64 ; \
|
||||
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then \
|
||||
RID=linux-musl-arm ; \
|
||||
fi \
|
||||
&& echo "RID=$RID" > /tmp/rid.txt
|
||||
|
||||
# Copy required project files
|
||||
WORKDIR /source
|
||||
COPY . ./
|
||||
|
||||
# Restore project dependencies and tools
|
||||
WORKDIR /source/bitwarden_license/src/Sso
|
||||
RUN . /tmp/rid.txt && dotnet restore -r $RID
|
||||
|
||||
# Build project
|
||||
RUN . /tmp/rid.txt && dotnet publish \
|
||||
-c release \
|
||||
--no-restore \
|
||||
--self-contained \
|
||||
/p:PublishSingleFile=true \
|
||||
-r $RID \
|
||||
-o out
|
||||
|
||||
###############################################
|
||||
# App stage #
|
||||
###############################################
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine3.21
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
LABEL com.bitwarden.product="bitwarden"
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
gosu \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV ASPNETCORE_URLS http://+:5000
|
||||
WORKDIR /app
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
ENV ASPNETCORE_URLS=http://+:5000
|
||||
ENV SSL_CERT_DIR=/etc/bitwarden/ca-certificates
|
||||
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false
|
||||
EXPOSE 5000
|
||||
COPY obj/build-output/publish .
|
||||
COPY entrypoint.sh /
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
krb5 \
|
||||
icu-libs \
|
||||
shadow \
|
||||
&& apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community gosu
|
||||
|
||||
# Copy app from the build stage
|
||||
WORKDIR /app
|
||||
COPY --from=build /source/bitwarden_license/src/Sso/out /app
|
||||
COPY ./bitwarden_license/src/Sso/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using Duende.IdentityServer.Models;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using Duende.IdentityServer.Models;
|
||||
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
namespace Bit.Sso.Models;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
public class RedirectViewModel
|
||||
{
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
// FIXME: Update this file to be null safe and then delete the line below
|
||||
#nullable disable
|
||||
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace Bit.Sso.Models;
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Bit.Core.Utilities;
|
||||
using Serilog;
|
||||
|
||||
namespace Bit.Sso;
|
||||
|
||||
@@ -13,19 +12,8 @@ public class Program
|
||||
.ConfigureWebHostDefaults(webBuilder =>
|
||||
{
|
||||
webBuilder.UseStartup<Startup>();
|
||||
webBuilder.ConfigureLogging((hostingContext, logging) =>
|
||||
logging.AddSerilog(hostingContext, (e, globalSettings) =>
|
||||
{
|
||||
var context = e.Properties["SourceContext"].ToString();
|
||||
if (e.Properties.ContainsKey("RequestPath") &&
|
||||
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
|
||||
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return e.Level >= globalSettings.MinLogLevel.SsoSettings.Default;
|
||||
}));
|
||||
})
|
||||
.AddSerilogFileLogging()
|
||||
.Build()
|
||||
.Run();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user