1
0
mirror of https://github.com/bitwarden/server.git synced 2025-02-07 00:21:32 +01:00

Merge branch 'main' into ac/ac-1682/ef-migrations

# Conflicts:
#	src/Sql/dbo/Stored Procedures/Organization_EnableCollectionEnhancements.sql
#	util/SqliteMigrations/SqliteMigrations.csproj
This commit is contained in:
Rui Tome 2024-02-16 12:47:39 +00:00
commit 39e336eddd
No known key found for this signature in database
GPG Key ID: 526239D96A8EC066
382 changed files with 22664 additions and 5025 deletions

13
.checkmarx/config.yml Normal file
View File

@ -0,0 +1,13 @@
version: 1
# Checkmarx configuration file
#
# https://checkmarx.com/resource/documents/en/34965-68549-configuring-projects-using-config-as-code-files.html
checkmarx:
scan:
configs:
sast:
# Exclude test directory
filter: "!test"
kics:
filter: "!dev,!.devcontainer"

View File

@ -7,7 +7,7 @@
"commands": ["swagger"]
},
"dotnet-ef": {
"version": "7.0.14",
"version": "8.0.1",
"commands": ["dotnet-ef"]
}
}

View File

@ -2,7 +2,7 @@ version: '3'
services:
bitwarden_server:
image: mcr.microsoft.com/devcontainers/dotnet:dev-6.0
image: mcr.microsoft.com/devcontainers/dotnet:8.0
volumes:
- ../../:/workspace:cached
# Overrides default command so things don't shut down after the process ends.

2
.github/codecov.yml vendored Normal file
View File

@ -0,0 +1,2 @@
ignore:
- "test" # Tests

59
.github/renovate.json vendored
View File

@ -1,17 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base",
":combinePatchMinorReleases",
":dependencyDashboard",
":maintainLockFilesWeekly",
":pinAllExceptPeerDependencies",
":prConcurrentLimit10",
":rebaseStalePrs",
":separateMajorReleases",
"group:monorepos",
"schedule:weekends"
],
"extends": ["github>bitwarden/renovate-config"],
"enabledManagers": [
"dockerfile",
"docker-compose",
@ -19,8 +8,6 @@
"npm",
"nuget"
],
"commitMessagePrefix": "[deps]:",
"commitMessageTopic": "{{depName}}",
"packageRules": [
{
"groupName": "dockerfile minor",
@ -112,27 +99,13 @@
"groupName": "Microsoft.Extensions.Logging",
"description": "Group Microsoft.Extensions.Logging to exclude them from the dotnet monorepo preset"
},
{
"matchPackageNames": ["CommandDotNet", "dbup-sqlserver", "YamlDotNet"],
"description": "DevOps owned dependencies",
"commitMessagePrefix": "[deps] DevOps:",
"reviewers": ["team:team-devops"]
},
{
"matchPackageNames": [
"Microsoft.AspNetCore.Authentication.JwtBearer",
"Microsoft.AspNetCore.Http",
"Microsoft.Data.SqlClient"
],
"description": "Platform owned dependencies",
"commitMessagePrefix": "[deps] Platform:",
"reviewers": ["team:team-platform-dev"]
},
{
"matchPackageNames": [
"Dapper",
"dbup-sqlserver",
"dotnet-ef",
"linq2db.EntityFrameworkCore",
"Microsoft.Data.SqlClient",
"Microsoft.EntityFrameworkCore.Design",
"Microsoft.EntityFrameworkCore.InMemory",
"Microsoft.EntityFrameworkCore.Relational",
@ -141,9 +114,24 @@
"Npgsql.EntityFrameworkCore.PostgreSQL",
"Pomelo.EntityFrameworkCore.MySql"
],
"description": "Secrets Manager owned dependencies",
"commitMessagePrefix": "[deps] SM:",
"reviewers": ["team:team-secrets-manager-dev"]
"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"],
@ -203,10 +191,5 @@
"reviewers": ["team:team-vault-dev"]
}
],
"force": {
"constraints": {
"dotnet": "6.0.100"
}
},
"ignoreDeps": ["dotnet-sdk"]
}

View File

@ -1,7 +0,0 @@
{
"release": {
"head": {
"ref": "master"
}
}
}

View File

@ -1,7 +1,6 @@
---
name: _move_finalization_db_scripts
run-name: Move finalization db scripts
run-name: Move finalization database scripts
on:
workflow_call:
@ -11,7 +10,6 @@ permissions:
contents: write
jobs:
setup:
name: Setup
runs-on: ubuntu-22.04
@ -19,7 +17,7 @@ jobs:
migration_filename_prefix: ${{ steps.prefix.outputs.prefix }}
copy_finalization_scripts: ${{ steps.check-finalization-scripts-existence.outputs.copy_finalization_scripts }}
steps:
- name: Login to Azure
- name: Log in to Azure
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -31,7 +29,7 @@ jobs:
keyvault: "bitwarden-ci"
secrets: "github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout Branch
- name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
@ -40,7 +38,7 @@ jobs:
id: prefix
run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Check if any files in db finalization
- name: Check if any files in DB finalization directory
id: check-finalization-scripts-existence
run: |
if [ -f util/Migrator/DbScripts_finalization/* ]; then
@ -50,7 +48,7 @@ jobs:
fi
move-finalization-db-scripts:
name: Move finalization db scripts
name: Move finalization database scripts
runs-on: ubuntu-22.04
needs: setup
if: ${{ needs.setup.outputs.copy_finalization_scripts == 'true' }}
@ -95,12 +93,12 @@ jobs:
done
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT
- name: Login to Azure - Prod Subscription
- name: Log in to Azure - production subscription
uses: Azure/login@de95379fe4dadc2defb305917eaa7e5dde727294 # v1.5.1
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
- name: Retrieve Secrets
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@main
with:
@ -140,7 +138,7 @@ jobs:
BRANCH: ${{ steps.branch_name.outputs.branch_name }}
GH_TOKEN: ${{ github.token }}
MOVED_FILES: ${{ steps.move-files.outputs.moved_files }}
TITLE: "Move finalization db scripts"
TITLE: "Move finalization database scripts"
run: |
PR_URL=$(gh pr create --title "$TITLE" \
--base "main" \

View File

@ -6,8 +6,8 @@ on:
- labeled
jobs:
close-issue:
name: 'Close issue with automatic response'
runs-on: ubuntu-20.04
name: Close issue with automatic response
runs-on: ubuntu-22.04
permissions:
issues: write
steps:
@ -24,7 +24,7 @@ jobs:
This issue will now be closed. Thanks!
# Intended behavior
- if: github.event.label.name == 'intended-behavior'
name: Intended behaviour
name: Intended behavior
uses: peter-evans/close-issue@1373cadf1f0c96c1420bc000cfba2273ea307fd1 # v2.2.0
with:
comment: |

View File

@ -2,93 +2,35 @@
name: Build
on:
push:
branches-ignore:
- "l10n_master"
- "gh-pages"
paths-ignore:
- ".github/workflows/**"
workflow_dispatch:
push:
branches:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
cloc:
name: CLOC
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Install cloc
run: |
sudo apt-get update
sudo apt-get -y install cloc
- name: Print lines of code
run: cloc --include-lang C#,SQL,Razor,"Bourne Shell",PowerShell,HTML,CSS,Sass,JavaScript,TypeScript --vcs git
lint:
name: Lint
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Verify Format
- name: Verify format
run: dotnet format --verify-no-changes
testing:
name: Testing
runs-on: ubuntu-22.04
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Checkout repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Print environment
run: |
dotnet --info
nuget help | grep Version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Remove SQL proj
run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj
- name: Test OSS solution
run: dotnet test ./test --configuration Release --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Test Bitwarden solution
run: dotnet test ./bitwarden_license/test --configuration Release --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
if: always()
with:
name: Test Results
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build-artifacts:
name: Build artifacts
runs-on: ubuntu-22.04
needs:
- testing
- lint
strategy:
fail-fast: false
@ -125,10 +67,10 @@ jobs:
base_path: ./bitwarden_license/src
node: true
steps:
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Set up Node
@ -228,7 +170,7 @@ jobs:
base_path: ./bitwarden_license/src
dotnet: true
steps:
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Check Branch to Publish
@ -245,7 +187,7 @@ jobs:
fi
########## ACRs ##########
- name: Login to Azure - PROD Subscription
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -253,7 +195,7 @@ jobs:
- name: Login to PROD ACR
run: az acr login -n bitwardenprod
- name: Login to Azure - CI Subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -269,13 +211,20 @@ jobs:
- name: Generate Docker image tag
id: tag
run: |
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name
if [[ $(grep "pull" <<< "${GITHUB_REF}") ]]; then
IMAGE_TAG=$(echo "${GITHUB_HEAD_REF}" | sed "s#/#-#g")
else
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g")
fi
if [[ "$IMAGE_TAG" == "main" ]]; then
IMAGE_TAG=dev
fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Setup project name
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY
- name: Set up project name
id: setup
run: |
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
@ -303,7 +252,7 @@ jobs:
with:
name: ${{ matrix.project_name }}.zip
- name: Setup build artifact
- name: Set up build artifact
if: ${{ matrix.dotnet }}
run: |
mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish
@ -326,13 +275,13 @@ jobs:
runs-on: ubuntu-22.04
needs: build-docker
steps:
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Login to Azure - PROD Subscription
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -445,7 +394,7 @@ jobs:
if-no-files-found: error
build-mssqlmigratorutility:
name: Build MsSqlMigratorUtility
name: Build MSSQL migrator utility
runs-on: ubuntu-22.04
needs: lint
defaults:
@ -460,10 +409,10 @@ jobs:
- linux-x64
- win-x64
steps:
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Print environment
@ -478,7 +427,7 @@ jobs:
dotnet publish -c "Release" -o obj/build-output/publish -r ${{ matrix.target }} -p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true --self-contained true
- name: Upload project artifact Windows
- name: Upload project artifact for Windows
if: ${{ contains(matrix.target, 'win') == true }}
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
@ -499,7 +448,7 @@ jobs:
runs-on: ubuntu-22.04
needs: build-docker
steps:
- name: Login to Azure - CI Subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -532,7 +481,7 @@ jobs:
runs-on: ubuntu-22.04
needs: build-docker
steps:
- name: Login to Azure - CI Subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -565,9 +514,7 @@ jobs:
if: always()
runs-on: ubuntu-22.04
needs:
- cloc
- lint
- testing
- build-artifacts
- build-docker
- upload
@ -581,7 +528,6 @@ jobs:
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc'
env:
CLOC_STATUS: ${{ needs.cloc.result }}
LINT_STATUS: ${{ needs.lint.result }}
TESTING_STATUS: ${{ needs.testing.result }}
BUILD_ARTIFACTS_STATUS: ${{ needs.build-artifacts.result }}
@ -591,9 +537,7 @@ jobs:
TRIGGER_SELF_HOST_BUILD_STATUS: ${{ needs.self-host-build.result }}
TRIGGER_K8S_DEPLOY_STATUS: ${{ needs.trigger-k8s-deploy.result }}
run: |
if [ "$CLOC_STATUS" = "failure" ]; then
exit 1
elif [ "$LINT_STATUS" = "failure" ]; then
if [ "$LINT_STATUS" = "failure" ]; then
exit 1
elif [ "$TESTING_STATUS" = "failure" ]; then
exit 1
@ -611,7 +555,7 @@ jobs:
exit 1
fi
- name: Login to Azure - CI subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
if: failure()
with:

View File

@ -1,5 +1,5 @@
---
name: Clean After PR
name: Container registry cleanup
on:
pull_request:
@ -7,32 +7,30 @@ on:
jobs:
build-docker:
name: Remove feature branch docker images
runs-on: ubuntu-20.04
name: Remove branch-specific Docker images
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
########## ACR ##########
- name: Login to Azure - QA Subscription
- name: Log in to Azure - QA Subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Login to Azure ACR
- name: Log in to Azure ACR
run: az acr login -n bitwardenqa
- name: Login to Azure - PROD Subscription
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Login to Azure ACR
- name: Log in to Azure ACR
run: az acr login -n bitwardenprod
########## Remove Docker images ##########
- name: Remove the docker image from ACR
- name: Remove the Docker image from ACR
env:
REF: ${{ github.event.pull_request.head.ref }}
REGISTRIES: |
registries:
- bitwardenprod
@ -59,7 +57,7 @@ jobs:
for REGISTRY in $( echo "${{ env.REGISTRIES }}" | yq e ".registries[]" - )
do
SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}')
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $REGISTRY.azurecr.io/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$(

42
.github/workflows/code-references.yml vendored Normal file
View File

@ -0,0 +1,42 @@
---
name: Collect code references
on:
pull_request:
branches-ignore:
- "renovate/**"
permissions:
contents: read
pull-requests: write
jobs:
refs:
name: Code reference collection
runs-on: ubuntu-22.04
steps:
- name: Check out repository
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Collect
id: collect
uses: launchdarkly/find-code-references-in-pull-request@2e9333c88539377cfbe818c265ba8b9ebced3c91 # v1.1.0
with:
project-key: default
environment-key: dev
access-token: ${{ secrets.LD_ACCESS_TOKEN }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Add label
if: steps.collect.outputs.any-changed == 'true'
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@ -1,18 +1,18 @@
---
name: Container Registry Purge
name: Container registry purge
on:
schedule:
- cron: '0 0 * * SUN'
- cron: "0 0 * * SUN"
workflow_dispatch:
inputs: {}
jobs:
purge:
name: Purge old images
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Login to Azure
- name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -68,7 +68,7 @@ jobs:
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
needs:
- purge
steps:
@ -84,7 +84,7 @@ jobs:
exit 1
fi
- name: Login to Azure - CI subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
if: failure()
with:

View File

@ -1,95 +0,0 @@
---
name: Validate Database
on:
pull_request:
branches-ignore:
- 'l10n_master'
- 'gh-pages'
paths:
- 'src/Sql/**'
- 'util/Migrator/**'
push:
branches:
- 'main'
- 'rc'
paths:
- 'src/Sql/**'
- 'util/Migrator/**'
workflow_dispatch:
inputs: {}
jobs:
validate:
name: Validate
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
with:
dotnet-version: '6.0.x'
- name: Print environment
run: |
dotnet --info
nuget help | grep Version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Build DACPAC
run: dotnet build src/Sql --configuration Release --verbosity minimal --output .
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: sql.dacpac
path: Sql.dacpac
- name: Docker Compose up
working-directory: "dev"
run: |
cp .env.example .env
docker compose --profile mssql up -d
shell: pwsh
- name: Migrate
working-directory: "dev"
run: "pwsh ./migrate.ps1"
shell: pwsh
- name: Diff sqlproj to migrations
run: /usr/local/sqlpackage/sqlpackage /action:DeployReport /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"report.xml" /p:IgnoreColumnOrder=True /p:IgnoreComments=True
shell: pwsh
- name: Generate SQL file
run: /usr/local/sqlpackage/sqlpackage /action:Script /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"diff.sql" /p:IgnoreColumnOrder=True /p:IgnoreComments=True
shell: pwsh
- name: Upload Report
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: report.xml
path: |
report.xml
diff.sql
- name: Validate XML
run: |
if grep -q "<Operations>" "report.xml"; then
echo
echo "Migrations are out of sync with sqlproj!"
exit 1
else
echo "Report looks good"
fi
shell: bash
- name: Docker compose down
if: ${{ always() }}
working-directory: "dev"
run: docker compose down
shell: pwsh

View File

@ -2,15 +2,18 @@
name: Enforce PR labels
on:
workflow_call:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
types: [labeled, unlabeled, opened, reopened, synchronize]
jobs:
enforce-label:
name: EnforceLabel
runs-on: ubuntu-20.04
if: ${{ contains(github.event.*.labels.*.name, 'hold') || contains(github.event.*.labels.*.name, 'needs-qa') || contains(github.event.*.labels.*.name, 'DB-migrations-changed') }}
name: Enforce label
runs-on: ubuntu-22.04
steps:
- name: Enforce Label
uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024 # 2.2.2
with:
BANNED_LABELS: "hold,DB-migrations-changed,needs-qa"
- 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
exit 1

View File

@ -1,117 +0,0 @@
---
name: Run Database Infrastructure Tests
on:
pull_request:
branches-ignore:
- 'l10n_master'
- 'gh-pages'
paths:
- '.github/workflows/infrastructure-tests.yml' # This file
- 'src/Sql/**' # SQL Server Database Changes
- 'util/Migrator/**' # New SQL Server Migrations
- 'util/MySqlMigrations/**' # Changes to MySQL
- 'util/PostgresMigrations/**' # Changes to Postgres
- 'util/SqliteMigrations/**' # Changes to Sqlite
- '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
push:
branches:
- 'main'
- 'rc'
paths:
- '.github/workflows/infrastructure-tests.yml' # This file
- 'src/Sql/**' # SQL Server Database Changes
- 'util/Migrator/**' # New SQL Server Migrations
- 'util/MySqlMigrations/**' # Changes to MySQL
- 'util/PostgresMigrations/**' # Changes to Postgres
- 'util/SqliteMigrations/**' # Changes to Sqlite
- '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
workflow_dispatch:
inputs: {}
jobs:
test:
name: 'Run Infrastructure.IntegrationTest'
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up dotnet
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
with:
dotnet-version: '6.0.x'
- name: Restore Tools
run: dotnet tool restore
- name: Compose Databases
working-directory: 'dev'
# We could think about not using profiles and pulling images directly to cover multiple versions
run: |
cp .env.example .env
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
# 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: Migrate SQL Server
working-directory: 'dev'
run: "pwsh ./migrate.ps1"
shell: pwsh
- name: Migrate MySQL
working-directory: 'util/MySqlMigrations'
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Migrate Postgres
working-directory: 'util/PostgresMigrations'
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
- name: Migrate Sqlite
working-directory: 'util/SqliteMigrations'
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Data Source=${{ runner.temp }}/test.db"
- name: Run Tests
working-directory: 'test/Infrastructure.IntegrationTest'
env:
# Default Postgres:
BW_TEST_DATABASES__0__TYPE: "Postgres"
BW_TEST_DATABASES__0__CONNECTIONSTRING: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
# Default MySql
BW_TEST_DATABASES__1__TYPE: "MySql"
BW_TEST_DATABASES__1__CONNECTIONSTRING: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev"
# Default Dapper SqlServer
BW_TEST_DATABASES__2__TYPE: "SqlServer"
BW_TEST_DATABASES__2__CONNECTIONSTRING: "Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;"
# 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"
shell: pwsh
- name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
if: always()
with:
name: Test Results
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
- name: Docker compose down
if: always()
working-directory: "dev"
run: docker compose down
shell: pwsh

View File

@ -2,8 +2,7 @@
# 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
name: Protect files
on:
pull_request:
@ -17,7 +16,7 @@ on:
jobs:
changed-files:
name: Check for file changes
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
outputs:
changes: ${{steps.check-changes.outputs.changes_detected}}
@ -29,7 +28,7 @@ jobs:
path: util/Migrator/DbScripts
label: "DB-migrations-changed"
steps:
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
with:
fetch-depth: 2

View File

@ -16,7 +16,7 @@ on:
- Dry Run
env:
_AZ_REGISTRY: 'bitwardenprod.azurecr.io'
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
setup:
@ -36,10 +36,10 @@ jobs:
exit 1
fi
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Check Release Version
- name: Check release version
id: version
uses: bitwarden/gh-actions/release-version-check@main
with:
@ -87,7 +87,7 @@ jobs:
task: "deploy"
description: "Deploy from ${{ needs.setup.outputs.branch-name }} branch"
- name: Download latest Release ${{ matrix.name }} asset
- name: Download latest release ${{ matrix.name }} asset
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
@ -96,7 +96,7 @@ jobs:
branch: ${{ needs.setup.outputs.branch-name }}
artifacts: ${{ matrix.name }}.zip
- name: Dry Run - Download latest Release ${{ matrix.name }} asset
- name: Dry run - Download latest release ${{ matrix.name }} asset
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
@ -105,7 +105,7 @@ jobs:
branch: main
artifacts: ${{ matrix.name }}.zip
- name: Login to Azure - CI subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -130,12 +130,12 @@ jobs:
echo "::add-mask::$publish_profile"
echo "publish-profile=$publish_profile" >> $GITHUB_OUTPUT
- name: Login to Azure
- name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Deploy App
- name: Deploy app
uses: azure/webapps-deploy@4bca689e4c7129e55923ea9c45401b22dc6aa96f # v2.2.11
with:
app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }}
@ -156,7 +156,7 @@ jobs:
fi
az webapp start -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
- name: Update ${{ matrix.name }} deployment status to Success
- name: Update ${{ matrix.name }} deployment status to success
if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }}
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
with:
@ -164,7 +164,7 @@ jobs:
state: "success"
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
- name: Update ${{ matrix.name }} deployment status to Failure
- name: Update ${{ matrix.name }} deployment status to failure
if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }}
uses: chrnorm/deployment-status@2afb7d27101260f4a764219439564d954d10b5b0 # v2.0.1
with:
@ -210,10 +210,10 @@ jobs:
echo "GitHub event: $GITHUB_EVENT"
echo "Github Release Option: $RELEASE_OPTION"
- name: Checkout repo
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Setup project name
- name: Set up project name
id: setup
run: |
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
@ -222,12 +222,12 @@ jobs:
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
########## ACR PROD ##########
- name: Login to Azure - PROD Subscription
- name: Log in to Azure - production subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Login to Azure ACR
- name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors
- name: Pull latest project image
@ -266,13 +266,13 @@ jobs:
run: docker logout
release:
name: Create GitHub Release
name: Create GitHub release
runs-on: ubuntu-22.04
needs:
- setup
- deploy
steps:
- name: Download latest Release Docker Stubs
- name: Download latest release Docker stubs
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:
@ -285,7 +285,7 @@ jobs:
docker-stub-EU-sha256.txt,
swagger.json"
- name: Dry Run - Download latest Release Docker Stubs
- name: Dry Run - Download latest release Docker stubs
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@main
with:

View File

@ -1,20 +1,20 @@
---
name: 'Close stale issues and PRs'
name: Staleness
on:
workflow_dispatch:
schedule: # Run once a day at 5.23am (arbitrary but should avoid peak loads on the hour)
- cron: '23 5 * * *'
- cron: "23 5 * * *"
jobs:
stale:
name: 'Check for stale issues and PRs'
runs-on: ubuntu-20.04
name: Check for stale issues and PRs
runs-on: ubuntu-22.04
steps:
- name: 'Run stale action'
- name: Check
uses: actions/stale@1160a2240286f5da8ec72b1c0816ce2481aabf84 # v8.0.0
with:
stale-issue-label: 'needs-reply'
stale-pr-label: 'needs-changes'
stale-issue-label: "needs-reply"
stale-pr-label: "needs-changes"
days-before-stale: -1 # Do not apply the stale labels automatically, this is a manual process
days-before-issue-close: 14 # Close issue if no further activity after X days
days-before-pr-close: 21 # Close PR if no further activity after X days

View File

@ -1,5 +1,5 @@
---
name: Stop Staging Slots
name: Stop staging slots
on:
workflow_dispatch:
@ -7,8 +7,8 @@ on:
jobs:
stop-slots:
name: Stop Slots
runs-on: ubuntu-20.04
name: Stop slots
runs-on: ubuntu-22.04
strategy:
fail-fast: false
matrix:
@ -28,7 +28,7 @@ jobs:
echo "NAME_LOWER: $NAME_LOWER"
echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
- name: Login to Azure - CI Subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -46,7 +46,7 @@ jobs:
echo "::add-mask::$webapp_name"
echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
- name: Login to Azure
- name: Log in to Azure
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}

185
.github/workflows/test-database.yml vendored Normal file
View File

@ -0,0 +1,185 @@
---
name: Database testing
on:
workflow_dispatch:
push:
branches:
- "main"
- "rc"
- "hotfix-rc"
paths:
- ".github/workflows/infrastructure-tests.yml" # This file
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
- "util/PostgresMigrations/**" # Changes to Postgres
- "util/SqliteMigrations/**" # Changes to Sqlite
- "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
pull_request:
paths:
- ".github/workflows/infrastructure-tests.yml" # This file
- "src/Sql/**" # SQL Server Database Changes
- "util/Migrator/**" # New SQL Server Migrations
- "util/MySqlMigrations/**" # Changes to MySQL
- "util/PostgresMigrations/**" # Changes to Postgres
- "util/SqliteMigrations/**" # Changes to Sqlite
- "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
jobs:
test:
name: Run tests
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Restore tools
run: dotnet tool restore
- name: Docker Compose databases
working-directory: "dev"
# We could think about not using profiles and pulling images directly to cover multiple versions
run: |
cp .env.example .env
docker compose --profile mssql --profile postgres --profile mysql up -d
shell: pwsh
# 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: Migrate SQL Server
working-directory: "dev"
run: "./migrate.ps1"
shell: pwsh
- name: Migrate MySQL
working-directory: "util/MySqlMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:MySql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev;Allow User Variables=true"
- name: Migrate Postgres
working-directory: "util/PostgresMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:PostgreSql:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
- name: Migrate SQLite
working-directory: "util/SqliteMigrations"
run: 'dotnet ef database update --connection "$CONN_STR" -- --GlobalSettings:Sqlite:ConnectionString="$CONN_STR"'
env:
CONN_STR: "Data Source=${{ runner.temp }}/test.db"
- name: Run tests
working-directory: "test/Infrastructure.IntegrationTest"
env:
# Default Postgres:
BW_TEST_DATABASES__0__TYPE: "Postgres"
BW_TEST_DATABASES__0__CONNECTIONSTRING: "Host=localhost;Username=postgres;Password=SET_A_PASSWORD_HERE_123;Database=vault_dev"
# Default MySql
BW_TEST_DATABASES__1__TYPE: "MySql"
BW_TEST_DATABASES__1__CONNECTIONSTRING: "server=localhost;uid=root;pwd=SET_A_PASSWORD_HERE_123;database=vault_dev"
# Default Dapper SqlServer
BW_TEST_DATABASES__2__TYPE: "SqlServer"
BW_TEST_DATABASES__2__CONNECTIONSTRING: "Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;"
# 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"
shell: pwsh
- name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
if: always()
with:
name: Test Results
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
- name: Docker Compose down
if: always()
working-directory: "dev"
run: docker compose down
shell: pwsh
validate:
name: Run validation
runs-on: ubuntu-22.04
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Print environment
run: |
dotnet --info
nuget help | grep Version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Build DACPAC
run: dotnet build src/Sql --configuration Release --verbosity minimal --output .
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: sql.dacpac
path: Sql.dacpac
- name: Docker Compose up
working-directory: "dev"
run: |
cp .env.example .env
docker compose --profile mssql up -d
shell: pwsh
- name: Migrate
working-directory: "dev"
run: "./migrate.ps1"
shell: pwsh
- name: Diff .sqlproj to migrations
run: /usr/local/sqlpackage/sqlpackage /action:DeployReport /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"report.xml" /p:IgnoreColumnOrder=True /p:IgnoreComments=True
shell: pwsh
- name: Generate SQL file
run: /usr/local/sqlpackage/sqlpackage /action:Script /SourceFile:"Sql.dacpac" /TargetConnectionString:"Server=localhost;Database=vault_dev;User Id=SA;Password=SET_A_PASSWORD_HERE_123;Encrypt=True;TrustServerCertificate=True;" /OutputPath:"diff.sql" /p:IgnoreColumnOrder=True /p:IgnoreComments=True
shell: pwsh
- name: Report validation results
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
with:
name: report.xml
path: |
report.xml
diff.sql
- name: Validate XML
run: |
if grep -q "<Operations>" "report.xml"; then
echo
echo "Migrations are out of sync with sqlproj!"
exit 1
else
echo "Report looks good"
fi
shell: bash
- name: Docker Compose down
if: ${{ always() }}
working-directory: "dev"
run: docker compose down
shell: pwsh

58
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,58 @@
---
name: Testing
on:
workflow_dispatch:
push:
branches:
- "main"
- "rc"
- "hotfix-rc"
pull_request:
env:
_AZ_REGISTRY: "bitwardenprod.azurecr.io"
jobs:
testing:
name: Run tests
if: ${{ startsWith(github.head_ref, 'version_bump_') == false }}
runs-on: ubuntu-22.04
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- name: Set up .NET
uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0
- name: Print environment
run: |
dotnet --info
nuget help | grep Version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Remove SQL project
run: dotnet sln bitwarden-server.sln remove src/Sql/Sql.sqlproj
- name: Test OSS solution
run: dotnet test ./test --configuration Debug --logger "trx;LogFileName=oss-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Test Bitwarden solution
run: dotnet test ./bitwarden_license/test --configuration Debug --logger "trx;LogFileName=bw-test-results.trx" /p:CoverletOutputFormatter="cobertura" --collect:"XPlat Code Coverage"
- name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226 # v1.6.0
if: always()
with:
name: Test Results
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
- name: Upload to codecov.io
uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d # v3.1.4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@ -1,6 +1,6 @@
---
name: Version Bump
run-name: Version Bump - v${{ inputs.version_number }}
name: Bump version
run-name: Bump version to ${{ inputs.version_number }}
on:
workflow_dispatch:
@ -16,10 +16,10 @@ on:
jobs:
bump_version:
name: "Bump Version to v${{ inputs.version_number }}"
name: Bump
runs-on: ubuntu-22.04
steps:
- name: Login to Azure - CI Subscription
- name: Log in to Azure - CI subscription
uses: Azure/login@92a5484dfaf04ca78a94597f4f19fea633851fa2 # v1.4.7
with:
creds: ${{ secrets.AZURE_KV_CI_SERVICE_PRINCIPAL }}
@ -33,11 +33,20 @@ jobs:
github-gpg-private-key-passphrase,
github-pat-bitwarden-devops-bot-repo-scope"
- name: Checkout Branch
- name: Check out branch
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
with:
ref: main
repository: bitwarden/server
- 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: Import GPG key
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6.0.0
@ -47,7 +56,7 @@ jobs:
git_user_signingkey: true
git_commit_gpgsign: true
- name: Create Version Branch
- name: Create version branch
id: create-branch
run: |
NAME=version_bump_${{ github.ref_name }}_${{ inputs.version_number }}
@ -78,13 +87,13 @@ jobs:
exit 1
fi
- name: Bump Version - Props
- name: Bump version props
uses: bitwarden/gh-actions/version-bump@main
with:
version: ${{ inputs.version_number }}
file_path: "Directory.Build.props"
- name: Setup git
- 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"
@ -109,7 +118,7 @@ jobs:
PR_BRANCH: ${{ steps.create-branch.outputs.name }}
run: git push -u origin $PR_BRANCH
- name: Create Version PR
- name: Create version PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
id: create-pr
env:
@ -152,28 +161,36 @@ jobs:
if: ${{ inputs.cut_rc_branch == true }}
runs-on: ubuntu-22.04
steps:
- name: Checkout Branch
- name: Check out branch
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
with:
ref: main
- name: Check if RC branch exists
- name: Install xmllint
run: sudo apt install -y libxml2-utils
- name: Verify version has been updated
env:
NEW_VERSION: ${{ inputs.version_number }}
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
# 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 future DB scripts
name: Move finalization database scripts
needs: cut_rc
uses: ./.github/workflows/_move_finalization_db_scripts.yml
secrets: inherit

View File

@ -1,11 +0,0 @@
---
name: Workflow Linter
on:
pull_request:
paths:
- .github/workflows/**
jobs:
call-workflow:
uses: bitwarden/gh-actions/.github/workflows/workflow-linter.yml@main

28
.vscode/launch.json vendored
View File

@ -252,7 +252,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Identity/bin/Debug/net6.0/Identity.dll",
"program": "${workspaceFolder}/src/Identity/bin/Debug/net8.0/Identity.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Identity",
"stopAtEntry": false,
@ -271,7 +271,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Api/bin/Debug/net6.0/Api.dll",
"program": "${workspaceFolder}/src/Api/bin/Debug/net8.0/Api.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Api",
"stopAtEntry": false,
@ -290,7 +290,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Billing/bin/Debug/net6.0/Billing.dll",
"program": "${workspaceFolder}/src/Billing/bin/Debug/net8.0/Billing.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Billing",
"stopAtEntry": false,
@ -310,7 +310,7 @@
"type": "coreclr",
"request": "launch",
"OS-COMMENT4": "If you have changed target frameworks, make sure to update the program path.",
"program": "${workspaceFolder}/src/Admin/bin/Debug/net6.0/Admin.dll",
"program": "${workspaceFolder}/src/Admin/bin/Debug/net8.0/Admin.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Admin",
"stopAtEntry": false,
@ -330,7 +330,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/bitwarden_license/src/Sso/bin/Debug/net6.0/Sso.dll",
"program": "${workspaceFolder}/bitwarden_license/src/Sso/bin/Debug/net8.0/Sso.dll",
"args": [],
"cwd": "${workspaceFolder}/bitwarden_license/src/Sso",
"stopAtEntry": false,
@ -349,7 +349,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/EventsProcessor/bin/Debug/net6.0/EventsProcessor.dll",
"program": "${workspaceFolder}/src/EventsProcessor/bin/Debug/net8.0/EventsProcessor.dll",
"args": [],
"cwd": "${workspaceFolder}/src/EventsProcessor",
"stopAtEntry": false,
@ -368,7 +368,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Icons/bin/Debug/net6.0/Icons.dll",
"program": "${workspaceFolder}/src/Icons/bin/Debug/net8.0/Icons.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Icons",
"stopAtEntry": false,
@ -387,7 +387,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Notifications/bin/Debug/net6.0/Notifications.dll",
"program": "${workspaceFolder}/src/Notifications/bin/Debug/net8.0/Notifications.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Notifications",
"stopAtEntry": false,
@ -406,7 +406,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Identity/bin/Debug/net6.0/Identity.dll",
"program": "${workspaceFolder}/src/Identity/bin/Debug/net8.0/Identity.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Identity",
"stopAtEntry": false,
@ -427,7 +427,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Api/bin/Debug/net6.0/Api.dll",
"program": "${workspaceFolder}/src/Api/bin/Debug/net8.0/Api.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Api",
"stopAtEntry": false,
@ -449,7 +449,7 @@
"type": "coreclr",
"request": "launch",
"OS-COMMENT4": "If you have changed target frameworks, make sure to update the program path.",
"program": "${workspaceFolder}/src/Admin/bin/Debug/net6.0/Admin.dll",
"program": "${workspaceFolder}/src/Admin/bin/Debug/net8.0/Admin.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Admin",
"stopAtEntry": false,
@ -471,7 +471,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/bitwarden_license/src/Sso/bin/Debug/net6.0/Sso.dll",
"program": "${workspaceFolder}/bitwarden_license/src/Sso/bin/Debug/net8.0/Sso.dll",
"args": [],
"cwd": "${workspaceFolder}/bitwarden_license/src/Sso",
"stopAtEntry": false,
@ -492,7 +492,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/Notifications/bin/Debug/net6.0/Notifications.dll",
"program": "${workspaceFolder}/src/Notifications/bin/Debug/net8.0/Notifications.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Notifications",
"stopAtEntry": false,
@ -513,7 +513,7 @@
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"program": "${workspaceFolder}/src/EventsProcessor/bin/Debug/net6.0/EventsProcessor.dll",
"program": "${workspaceFolder}/src/EventsProcessor/bin/Debug/net8.0/EventsProcessor.dll",
"args": [],
"cwd": "${workspaceFolder}/src/EventsProcessor",
"stopAtEntry": false,

View File

@ -1,8 +1,10 @@
<Project>
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>2024.1.0</Version>
<TargetFramework>net8.0</TargetFramework>
<Version>2024.2.2</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
@ -17,31 +19,31 @@
<!--
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
-->
<MicrosoftNetTestSdkVersion>17.1.0</MicrosoftNetTestSdkVersion>
<MicrosoftNetTestSdkVersion>17.8.0</MicrosoftNetTestSdkVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
-->
<XUnitVersion>2.4.1</XUnitVersion>
<XUnitVersion>2.6.6</XUnitVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
NuGet: https://www.nuget.org/packages/xunit.runner.visualstudio
-->
<XUnitRunnerVisualStudioVersion>2.4.3</XUnitRunnerVisualStudioVersion>
<XUnitRunnerVisualStudioVersion>2.5.6</XUnitRunnerVisualStudioVersion>
<!--
NuGet: https://www.nuget.org/packages/coverlet.collector/
NuGet: https://www.nuget.org/packages/coverlet.collector
-->
<CoverletCollectorVersion>3.1.2</CoverletCollectorVersion>
<CoverletCollectorVersion>6.0.0</CoverletCollectorVersion>
<!--
NuGet: https://www.nuget.org/packages/NSubstitute/
NuGet: https://www.nuget.org/packages/NSubstitute
-->
<NSubstituteVersion>4.3.0</NSubstituteVersion>
<NSubstituteVersion>5.1.0</NSubstituteVersion>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2/
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2
-->
<AutoFixtureXUnit2Version>4.17.0</AutoFixtureXUnit2Version>
<AutoFixtureXUnit2Version>4.18.1</AutoFixtureXUnit2Version>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute/
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute
-->
<AutoFixtureAutoNSubstituteVersion>4.17.0</AutoFixtureAutoNSubstituteVersion>
<AutoFixtureAutoNSubstituteVersion>4.18.1</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
<!--

View File

@ -5,13 +5,13 @@ specifies another license. Bitwarden Licensed code is found only in the
/bitwarden_license directory.
AGPL v3.0:
https://github.com/bitwarden/server/blob/master/LICENSE_AGPL.txt
https://github.com/bitwarden/server/blob/main/LICENSE_AGPL.txt
Bitwarden License v1.0:
https://github.com/bitwarden/server/blob/master/LICENSE_BITWARDEN.txt
https://github.com/bitwarden/server/blob/main/LICENSE_BITWARDEN.txt
No grant of any rights in the trademarks, service marks, or logos of Bitwarden is
made (except as may be necessary to comply with the notice requirements as
applicable), and use of any Bitwarden trademarks must comply with Bitwarden
Trademark Guidelines
<https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md>.
<https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md>.

View File

@ -56,7 +56,7 @@ such Open Source Software only.
logos of any Contributor (except as may be necessary to comply with the notice
requirements in Section 2.3), and use of any Bitwarden trademarks must comply with
Bitwarden Trademark Guidelines
<https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md>.
<https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md>.
3. TERMINATION

View File

@ -8,7 +8,7 @@ As an open solution, Bitwarden publishes the source code for various modules und
# Bitwarden Software Licensing
We have two tiers of licensing for our software. The core products are offered under one of the GPL open source licenses: GPL 3 and A-GPL 3. A select number of features, primarily those designed for use by larger organizations rather than individuals and families, are licensed under a "Source Available" commercial license [here](https://github.com/bitwarden/server/blob/master/LICENSE_BITWARDEN.txt).
We have two tiers of licensing for our software. The core products are offered under one of the GPL open source licenses: GPL 3 and A-GPL 3. A select number of features, primarily those designed for use by larger organizations rather than individuals and families, are licensed under a "Source Available" commercial license [here](https://github.com/bitwarden/server/blob/main/LICENSE_BITWARDEN.txt).
Our current software products have the following licenses:
@ -49,7 +49,7 @@ As detailed above, the Bitwarden password management clients for individual use,
***If I redistribute or provide services related to Bitwarden open source software can I use the "Bitwarden" name?***
Our licenses do not grant any rights in the trademarks, service marks, or logos of Bitwarden (except as may be necessary to comply with the notice requirements as applicable). The Bitwarden trademark is a trusted mark applied to products distributed by Bitwarden, Inc., owner of the Bitwarden trademarks and products. We have adopted and enforce strict rules governing use of our trademarks. Use of any Bitwarden trademarks must comply with Bitwarden [Trademark Guidelines](https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md).
Our licenses do not grant any rights in the trademarks, service marks, or logos of Bitwarden (except as may be necessary to comply with the notice requirements as applicable). The Bitwarden trademark is a trusted mark applied to products distributed by Bitwarden, Inc., owner of the Bitwarden trademarks and products. We have adopted and enforce strict rules governing use of our trademarks. Use of any Bitwarden trademarks must comply with Bitwarden [Trademark Guidelines](https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md).
***Bitwarden Trademark Usage***
@ -61,10 +61,10 @@ You don't need permission to use our marks when truthfully referring to our prod
***How should I use the Bitwarden Trademarks when allowed?***
Use the Bitwarden Trademarks exactly as [shown](https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md) and without modification. For example, do not abbreviate, hyphenate, or remove elements and separate them from surrounding text, images and other features. Always use the Bitwarden Trademarks as adjectives followed by a generic term, never as a noun or verb.
Use the Bitwarden Trademarks exactly as [shown](https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md) and without modification. For example, do not abbreviate, hyphenate, or remove elements and separate them from surrounding text, images and other features. Always use the Bitwarden Trademarks as adjectives followed by a generic term, never as a noun or verb.
Use the Bitwarden Trademarks only to reference one of our products or services, but never in a way that implies sponsorship or affiliation by Bitwarden. For example, do not use any part of the Bitwarden Trademarks as the name of your business, product or service name, application, domain name, publication or other offering this can be confusing to others.
***Where can I find more information?***
For more information on how to use the Bitwarden Trademarks, including in connection with self-hosted options and open-source code, see our [Trademark Guidelines](https://github.com/bitwarden/server/blob/master/TRADEMARK_GUIDELINES.md) or [contacts us](https://bitwarden.com/contact/).
For more information on how to use the Bitwarden Trademarks, including in connection with self-hosted options and open-source code, see our [Trademark Guidelines](https://github.com/bitwarden/server/blob/main/TRADEMARK_GUIDELINES.md) or [contacts us](https://bitwarden.com/contact/).

View File

@ -2,8 +2,8 @@
<img src="https://github.com/bitwarden/brand/blob/main/screenshots/apps-combo-logo.png" alt="Bitwarden" />
</p>
<p align="center">
<a href="https://github.com/bitwarden/server/actions/workflows/build.yml?query=branch:master" target="_blank">
<img src="https://github.com/bitwarden/server/actions/workflows/build.yml/badge.svg?branch=master" alt="Github Workflow build on master" />
<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" />

View File

@ -1,3 +1,3 @@
# Bitwarden Licensed Code
All source code under this directory is licensed under the [Bitwarden License Agreement](https://github.com/bitwarden/server/blob/master/LICENSE_BITWARDEN.txt).
All source code under this directory is licensed under the [Bitwarden License Agreement](https://github.com/bitwarden/server/blob/main/LICENSE_BITWARDEN.txt).

View File

@ -0,0 +1,98 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.Extensions.Logging;
using Stripe;
namespace Bit.Commercial.Core.AdminConsole.Providers;
public class RemoveOrganizationFromProviderCommand : IRemoveOrganizationFromProviderCommand
{
private readonly IEventService _eventService;
private readonly ILogger<RemoveOrganizationFromProviderCommand> _logger;
private readonly IMailService _mailService;
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationService _organizationService;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IStripeAdapter _stripeAdapter;
public RemoveOrganizationFromProviderCommand(
IEventService eventService,
ILogger<RemoveOrganizationFromProviderCommand> logger,
IMailService mailService,
IOrganizationRepository organizationRepository,
IOrganizationService organizationService,
IProviderOrganizationRepository providerOrganizationRepository,
IStripeAdapter stripeAdapter)
{
_eventService = eventService;
_logger = logger;
_mailService = mailService;
_organizationRepository = organizationRepository;
_organizationService = organizationService;
_providerOrganizationRepository = providerOrganizationRepository;
_stripeAdapter = stripeAdapter;
}
public async Task RemoveOrganizationFromProvider(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization)
{
if (provider == null ||
providerOrganization == null ||
organization == null ||
providerOrganization.ProviderId != provider.Id)
{
throw new BadRequestException("Failed to remove organization. Please contact support.");
}
if (!await _organizationService.HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
includeProvider: false))
{
throw new BadRequestException("Organization must have at least one confirmed owner.");
}
var organizationOwnerEmails =
(await _organizationRepository.GetOwnerEmailAddressesById(organization.Id)).ToList();
organization.BillingEmail = organizationOwnerEmails.MinBy(email => email);
await _organizationRepository.ReplaceAsync(organization);
var customerUpdateOptions = new CustomerUpdateOptions
{
Coupon = string.Empty,
Email = organization.BillingEmail
};
await _stripeAdapter.CustomerUpdateAsync(organization.GatewayCustomerId, customerUpdateOptions);
var subscriptionUpdateOptions = new SubscriptionUpdateOptions
{
CollectionMethod = "send_invoice",
DaysUntilDue = 30
};
await _stripeAdapter.SubscriptionUpdateAsync(organization.GatewaySubscriptionId, subscriptionUpdateOptions);
await _mailService.SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
organizationOwnerEmails);
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
}
}

View File

@ -496,7 +496,7 @@ public class ProviderService : IProviderService
{
ThrowOnInvalidPlanType(organizationSignup.Plan);
var (organization, _) = await _organizationService.SignUpAsync(organizationSignup, true);
var (organization, _, defaultCollection) = await _organizationService.SignUpAsync(organizationSignup, true);
var providerOrganization = new ProviderOrganization
{
@ -508,6 +508,21 @@ public class ProviderService : IProviderService
await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
// If using Flexible Collections, give the owner Can Manage access over the default collection
// The orgUser is not available when the org is created so we have to do it here as part of the invite
var defaultOwnerAccess = organization.FlexibleCollections && defaultCollection != null
?
[
new CollectionAccessSelection
{
Id = defaultCollection.Id,
HidePasswords = false,
ReadOnly = false,
Manage = true
}
]
: Array.Empty<CollectionAccessSelection>();
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
new (OrganizationUserInvite, string)[]
{
@ -515,10 +530,13 @@ public class ProviderService : IProviderService
new OrganizationUserInvite
{
Emails = new[] { clientOwnerEmail },
AccessAll = true,
// If using Flexible Collections, AccessAll is deprecated and set to false.
// If not using Flexible Collections, set AccessAll to true (previous behavior)
AccessAll = !organization.FlexibleCollections,
Type = OrganizationUserType.Owner,
Permissions = null,
Collections = Array.Empty<CollectionAccessSelection>(),
Collections = defaultOwnerAccess,
},
null
)
@ -527,23 +545,6 @@ public class ProviderService : IProviderService
return providerOrganization;
}
public async Task RemoveOrganizationAsync(Guid providerId, Guid providerOrganizationId, Guid removingUserId)
{
var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(providerOrganizationId);
if (providerOrganization == null || providerOrganization.ProviderId != providerId)
{
throw new BadRequestException("Invalid organization.");
}
if (!await _organizationService.HasConfirmedOwnersExceptAsync(providerOrganization.OrganizationId, new Guid[] { }, includeProvider: false))
{
throw new BadRequestException("Organization needs to have at least one confirmed owner.");
}
await _providerOrganizationRepository.DeleteAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
}
public async Task ResendProviderSetupInviteEmailAsync(Guid providerId, Guid ownerId)
{
var provider = await _providerRepository.GetByIdAsync(providerId);

View File

@ -26,7 +26,7 @@ public class CountNewServiceAccountSlotsRequiredQuery : ICountNewServiceAccountS
throw new NotFoundException();
}
if (!organization.SmServiceAccounts.HasValue || serviceAccountsToAdd == 0 || organization.SecretsManagerBeta)
if (!organization.SmServiceAccounts.HasValue || serviceAccountsToAdd == 0)
{
return 0;
}

View File

@ -12,5 +12,6 @@ public static class ServiceCollectionExtensions
{
services.AddScoped<IProviderService, ProviderService>();
services.AddScoped<ICreateProviderCommand, CreateProviderCommand>();
services.AddScoped<IRemoveOrganizationFromProviderCommand, RemoveOrganizationFromProviderCommand>();
}
}

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:6.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"

View File

@ -209,6 +209,8 @@ public class AccountController : Controller
returnUrl = "~/";
}
// Clean the returnUrl
returnUrl = CoreHelpers.ReplaceWhiteSpace(returnUrl, string.Empty);
if (!Url.IsLocalUrl(returnUrl) && !_interaction.IsValidReturnUrl(returnUrl))
{
throw new Exception(_i18nService.T("InvalidReturnUrl"));

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:6.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"

View File

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

View File

@ -65,7 +65,7 @@ public class Startup
}
// Authentication
services.AddDistributedIdentityServices(globalSettings);
services.AddDistributedIdentityServices();
services.AddAuthentication()
.AddCookie(AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
services.AddSsoServices(globalSettings);

View File

@ -349,7 +349,9 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
}
var spEntityId = new Sustainsys.Saml2.Metadata.EntityId(
SsoConfigurationData.BuildSaml2ModulePath(_globalSettings.BaseServiceUri.Sso));
SsoConfigurationData.BuildSaml2ModulePath(
_globalSettings.BaseServiceUri.Sso,
config.SpUniqueEntityId ? name : null));
bool? allowCreate = null;
if (config.SpNameIdFormat != Saml2NameIdFormat.Transient)
{
@ -415,7 +417,7 @@ public class DynamicAuthenticationSchemeProvider : AuthenticationSchemeProvider
};
options.IdentityProviders.Add(idp);
return new DynamicAuthenticationScheme(name, name, typeof(Saml2BitHandler), options, SsoType.Saml2);
return new DynamicAuthenticationScheme(name, name, typeof(Saml2Handler), options, SsoType.Saml2);
}
private NameIdFormat GetNameIdFormat(Saml2NameIdFormat format)

View File

@ -1,205 +0,0 @@
using System.Text;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using Sustainsys.Saml2.AspNetCore2;
using Sustainsys.Saml2.WebSso;
namespace Bit.Sso.Utilities;
// Temporary handler for validating Saml2 requests
// Most of this is taken from Sustainsys.Saml2.AspNetCore2.Saml2Handler
// TODO: PM-3641 - Remove this handler once there is a proper solution
public class Saml2BitHandler : IAuthenticationRequestHandler
{
private readonly Saml2Handler _saml2Handler;
private string _scheme;
private readonly IOptionsMonitorCache<Saml2Options> _optionsCache;
private Saml2Options _options;
private HttpContext _context;
private readonly IDataProtector _dataProtector;
private readonly IOptionsFactory<Saml2Options> _optionsFactory;
private bool _emitSameSiteNone;
public Saml2BitHandler(
IOptionsMonitorCache<Saml2Options> optionsCache,
IDataProtectionProvider dataProtectorProvider,
IOptionsFactory<Saml2Options> optionsFactory)
{
if (dataProtectorProvider == null)
{
throw new ArgumentNullException(nameof(dataProtectorProvider));
}
_optionsFactory = optionsFactory;
_optionsCache = optionsCache;
_saml2Handler = new Saml2Handler(optionsCache, dataProtectorProvider, optionsFactory);
_dataProtector = dataProtectorProvider.CreateProtector(_saml2Handler.GetType().FullName);
}
public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
_options = _optionsCache.GetOrAdd(scheme.Name, () => _optionsFactory.Create(scheme.Name));
_emitSameSiteNone = _options.Notifications.EmitSameSiteNone(context.Request.GetUserAgent());
_scheme = scheme.Name;
return _saml2Handler.InitializeAsync(scheme, context);
}
public async Task<bool> HandleRequestAsync()
{
if (!_context.Request.Path.StartsWithSegments(_options.SPOptions.ModulePath, StringComparison.Ordinal))
{
return false;
}
var commandName = _context.Request.Path.Value.Substring(
_options.SPOptions.ModulePath.Length).TrimStart('/');
var commandResult = CommandFactory.GetCommand(commandName).Run(
_context.ToHttpRequestData(_options.CookieManager, _dataProtector.Unprotect), _options);
// Scheme is the organization ID since we use dynamic handlers for authentication schemes.
// We need to compare this to the scheme returned in the RelayData to ensure this value hasn't been
// tampered with
if (commandResult.RelayData["scheme"] != _scheme)
{
return false;
}
await commandResult.Apply(
_context, _dataProtector, _options.CookieManager, _options.SignInScheme, _options.SignOutScheme, _emitSameSiteNone);
return true;
}
public Task<AuthenticateResult> AuthenticateAsync() => _saml2Handler.AuthenticateAsync();
public Task ChallengeAsync(AuthenticationProperties properties) => _saml2Handler.ChallengeAsync(properties);
public Task ForbidAsync(AuthenticationProperties properties) => _saml2Handler.ForbidAsync(properties);
}
static class HttpRequestExtensions
{
public static HttpRequestData ToHttpRequestData(
this HttpContext httpContext,
ICookieManager cookieManager,
Func<byte[], byte[]> cookieDecryptor)
{
var request = httpContext.Request;
var uri = new Uri(
request.Scheme
+ "://"
+ request.Host
+ request.Path
+ request.QueryString);
var pathBase = httpContext.Request.PathBase.Value;
pathBase = string.IsNullOrEmpty(pathBase) ? "/" : pathBase;
IEnumerable<KeyValuePair<string, IEnumerable<string>>> formData = null;
if (httpContext.Request.Method == "POST" && httpContext.Request.HasFormContentType)
{
formData = request.Form.Select(
f => new KeyValuePair<string, IEnumerable<string>>(f.Key, f.Value));
}
return new HttpRequestData(
httpContext.Request.Method,
uri,
pathBase,
formData,
cookieName => cookieManager.GetRequestCookie(httpContext, cookieName),
cookieDecryptor,
httpContext.User);
}
public static string GetUserAgent(this HttpRequest request)
{
return request.Headers["user-agent"].FirstOrDefault() ?? "";
}
}
static class CommandResultExtensions
{
public static async Task Apply(
this CommandResult commandResult,
HttpContext httpContext,
IDataProtector dataProtector,
ICookieManager cookieManager,
string signInScheme,
string signOutScheme,
bool emitSameSiteNone)
{
httpContext.Response.StatusCode = (int)commandResult.HttpStatusCode;
if (commandResult.Location != null)
{
httpContext.Response.Headers["Location"] = commandResult.Location.OriginalString;
}
if (!string.IsNullOrEmpty(commandResult.SetCookieName))
{
var cookieData = HttpRequestData.ConvertBinaryData(
dataProtector.Protect(commandResult.GetSerializedRequestState()));
cookieManager.AppendResponseCookie(
httpContext,
commandResult.SetCookieName,
cookieData,
new CookieOptions()
{
HttpOnly = true,
Secure = commandResult.SetCookieSecureFlag,
// We are expecting a different site to POST back to us,
// so the ASP.Net Core default of Lax is not appropriate in this case
SameSite = emitSameSiteNone ? SameSiteMode.None : (SameSiteMode)(-1),
IsEssential = true
});
}
foreach (var h in commandResult.Headers)
{
httpContext.Response.Headers.Add(h.Key, h.Value);
}
if (!string.IsNullOrEmpty(commandResult.ClearCookieName))
{
cookieManager.DeleteCookie(
httpContext,
commandResult.ClearCookieName,
new CookieOptions
{
Secure = commandResult.SetCookieSecureFlag
});
}
if (!string.IsNullOrEmpty(commandResult.Content))
{
var buffer = Encoding.UTF8.GetBytes(commandResult.Content);
httpContext.Response.ContentType = commandResult.ContentType;
await httpContext.Response.Body.WriteAsync(buffer, 0, buffer.Length);
}
if (commandResult.Principal != null)
{
var authProps = new AuthenticationProperties(commandResult.RelayData)
{
RedirectUri = commandResult.Location.OriginalString
};
await httpContext.SignInAsync(signInScheme, commandResult.Principal, authProps);
}
if (commandResult.TerminateLocalSession)
{
await httpContext.SignOutAsync(signOutScheme ?? signInScheme);
}
}
}

View File

@ -0,0 +1,132 @@
using Bit.Commercial.Core.AdminConsole.Providers;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using NSubstitute;
using Stripe;
using Xunit;
namespace Bit.Commercial.Core.Test.AdminConsole.ProviderFeatures;
[SutProviderCustomize]
public class RemoveOrganizationFromProviderCommandTests
{
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoProvider_BadRequest(
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(null, null, null));
Assert.Equal("Failed to remove organization. Please contact support.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoProviderOrganization_BadRequest(
Provider provider,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, null, null));
Assert.Equal("Failed to remove organization. Please contact support.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoOrganization_BadRequest(
Provider provider,
ProviderOrganization providerOrganization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(
provider, providerOrganization, null));
Assert.Equal("Failed to remove organization. Please contact support.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MismatchedProviderOrganization_BadRequest(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization));
Assert.Equal("Failed to remove organization. Please contact support.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_NoConfirmedOwners_BadRequest(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
includeProvider: false)
.Returns(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(() => sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization));
Assert.Equal("Organization must have at least one confirmed owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganizationFromProvider_MakesCorrectInvocations(
Provider provider,
ProviderOrganization providerOrganization,
Organization organization,
SutProvider<RemoveOrganizationFromProviderCommand> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
var organizationRepository = sutProvider.GetDependency<IOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(
providerOrganization.OrganizationId,
Array.Empty<Guid>(),
includeProvider: false)
.Returns(true);
var organizationOwnerEmails = new List<string> { "a@gmail.com", "b@gmail.com" };
organizationRepository.GetOwnerEmailAddressesById(organization.Id).Returns(organizationOwnerEmails);
await sutProvider.Sut.RemoveOrganizationFromProvider(provider, providerOrganization, organization);
await organizationRepository.Received(1).ReplaceAsync(Arg.Is<Organization>(
org => org.Id == organization.Id && org.BillingEmail == "a@gmail.com"));
var stripeAdapter = sutProvider.GetDependency<IStripeAdapter>();
await stripeAdapter.Received(1).CustomerUpdateAsync(
organization.GatewayCustomerId, Arg.Is<CustomerUpdateOptions>(
options => options.Coupon == string.Empty && options.Email == "a@gmail.com"));
await stripeAdapter.Received(1).SubscriptionUpdateAsync(
organization.GatewaySubscriptionId, Arg.Is<SubscriptionUpdateOptions>(
options => options.CollectionMethod == "send_invoice" && options.DaysUntilDue == 30));
await sutProvider.GetDependency<IMailService>().Received(1).SendProviderUpdatePaymentMethod(
organization.Id,
organization.Name,
provider.Name,
Arg.Is<IEnumerable<string>>(emails => emails.Contains("a@gmail.com") && emails.Contains("b@gmail.com")));
await sutProvider.GetDependency<IProviderOrganizationRepository>().Received(1)
.DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received(1).LogProviderOrganizationEventAsync(
providerOrganization,
EventType.ProviderOrganization_Removed);
}
}

View File

@ -12,6 +12,7 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Test.AutoFixture.OrganizationFixtures;
using Bit.Core.Utilities;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
@ -513,7 +514,7 @@ public class ProviderServiceTests
await sutProvider.GetDependency<IEventService>().DidNotReceiveWithAnyArgs().LogProviderOrganizationEventsAsync(default);
}
[Theory, BitAutoData]
[Theory, OrganizationCustomize(FlexibleCollections = false), BitAutoData]
public async Task CreateOrganizationAsync_Success(Provider provider, OrganizationSignup organizationSignup,
Organization organization, string clientOwnerEmail, User user, SutProvider<ProviderService> sutProvider)
{
@ -522,7 +523,7 @@ public class ProviderServiceTests
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
.Returns(Tuple.Create(organization, null as OrganizationUser));
.Returns((organization, null as OrganizationUser, new Collection()));
var providerOrganization =
await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
@ -538,70 +539,45 @@ public class ProviderServiceTests
t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll &&
!t.First().Item1.Collections.Any() &&
t.First().Item2 == null));
}
[Theory, OrganizationCustomize(FlexibleCollections = true), BitAutoData]
public async Task CreateOrganizationAsync_WithFlexibleCollections_SetsAccessAllToFalse
(Provider provider, OrganizationSignup organizationSignup, Organization organization, string clientOwnerEmail,
User user, SutProvider<ProviderService> sutProvider, Collection defaultCollection)
{
organizationSignup.Plan = PlanType.EnterpriseAnnually;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
sutProvider.GetDependency<IOrganizationService>().SignUpAsync(organizationSignup, true)
.Returns((organization, null as OrganizationUser, defaultCollection));
var providerOrganization =
await sutProvider.Sut.CreateOrganizationAsync(provider.Id, organizationSignup, clientOwnerEmail, user);
await providerOrganizationRepository.ReceivedWithAnyArgs().CreateAsync(default);
await sutProvider.GetDependency<IEventService>()
.Received().LogProviderOrganizationEventAsync(providerOrganization,
EventType.ProviderOrganization_Created);
await sutProvider.GetDependency<IOrganizationService>()
.Received().InviteUsersAsync(organization.Id, user.Id, Arg.Is<IEnumerable<(OrganizationUserInvite, string)>>(
t => t.Count() == 1 &&
t.First().Item1.Emails.Count() == 1 &&
t.First().Item1.Emails.First() == clientOwnerEmail &&
t.First().Item1.Type == OrganizationUserType.Owner &&
t.First().Item1.AccessAll == false &&
t.First().Item1.Collections.Single().Id == defaultCollection.Id &&
!t.First().Item1.Collections.Single().HidePasswords &&
!t.First().Item1.Collections.Single().ReadOnly &&
t.First().Item1.Collections.Single().Manage &&
t.First().Item2 == null));
}
[Theory, BitAutoData]
public async Task RemoveOrganization_ProviderOrganizationIsInvalid_Throws(Provider provider,
ProviderOrganization providerOrganization, User user, SutProvider<ProviderService> sutProvider)
{
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganization.Id)
.ReturnsNull();
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id));
Assert.Equal("Invalid organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganization_ProviderOrganizationBelongsToWrongProvider_Throws(Provider provider,
ProviderOrganization providerOrganization, User user, SutProvider<ProviderService> sutProvider)
{
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganization.Id)
.Returns(providerOrganization);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id));
Assert.Equal("Invalid organization.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganization_HasNoOwners_Throws(Provider provider,
ProviderOrganization providerOrganization, User user, SutProvider<ProviderService> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
sutProvider.GetDependency<IProviderOrganizationRepository>().GetByIdAsync(providerOrganization.Id)
.Returns(providerOrganization);
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(default, default, default)
.ReturnsForAnyArgs(false);
var exception = await Assert.ThrowsAsync<BadRequestException>(
() => sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id));
Assert.Equal("Organization needs to have at least one confirmed owner.", exception.Message);
}
[Theory, BitAutoData]
public async Task RemoveOrganization_Success(Provider provider,
ProviderOrganization providerOrganization, User user, SutProvider<ProviderService> sutProvider)
{
providerOrganization.ProviderId = provider.Id;
sutProvider.GetDependency<IProviderRepository>().GetByIdAsync(provider.Id).Returns(provider);
var providerOrganizationRepository = sutProvider.GetDependency<IProviderOrganizationRepository>();
providerOrganizationRepository.GetByIdAsync(providerOrganization.Id).Returns(providerOrganization);
sutProvider.GetDependency<IOrganizationService>().HasConfirmedOwnersExceptAsync(default, default, default)
.ReturnsForAnyArgs(true);
await sutProvider.Sut.RemoveOrganizationAsync(provider.Id, providerOrganization.Id, user.Id);
await providerOrganizationRepository.Received().DeleteAsync(providerOrganization);
await sutProvider.GetDependency<IEventService>().Received()
.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Removed);
}
[Theory, BitAutoData]
public async Task AddOrganization_CreateAfterNov162023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key,
public async Task AddOrganization_CreateAfterNov62023_PlanTypeDoesNotUpdated(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
provider.Type = ProviderType.Msp;
@ -623,10 +599,10 @@ public class ProviderServiceTests
}
[Theory, BitAutoData]
public async Task AddOrganization_CreateBeforeNov162023_PlanTypeUpdated(Provider provider, Organization organization, string key,
public async Task AddOrganization_CreateBeforeNov62023_PlanTypeUpdated(Provider provider, Organization organization, string key,
SutProvider<ProviderService> sutProvider)
{
var newCreationDate = DateTime.UtcNow.AddMonths(-3);
var newCreationDate = new DateTime(2023, 11, 5);
BackdateProviderCreationDate(provider, newCreationDate);
provider.Type = ProviderType.Msp;

View File

@ -28,7 +28,6 @@ public class CountNewServiceAccountSlotsRequiredQueryTests
{
organization.UseSecretsManager = true;
organization.SmServiceAccounts = organizationSmServiceAccounts;
organization.SecretsManagerBeta = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
@ -62,7 +61,6 @@ public class CountNewServiceAccountSlotsRequiredQueryTests
organization.UseSecretsManager = true;
organization.SmServiceAccounts = null;
organization.SecretsManagerBeta = false;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
@ -80,27 +78,6 @@ public class CountNewServiceAccountSlotsRequiredQueryTests
.GetServiceAccountCountByOrganizationIdAsync(default);
}
[Theory, BitAutoData]
public async Task CountNewServiceAccountSlotsRequiredAsync_WithSecretsManagerBeta_ReturnsZero(
int serviceAccountsToAdd,
Organization organization,
SutProvider<CountNewServiceAccountSlotsRequiredQuery> sutProvider)
{
organization.UseSecretsManager = true;
organization.SecretsManagerBeta = true;
sutProvider.GetDependency<IOrganizationRepository>()
.GetByIdAsync(organization.Id)
.Returns(organization);
var result = await sutProvider.Sut.CountNewServiceAccountSlotsRequiredAsync(organization.Id, serviceAccountsToAdd);
Assert.Equal(0, result);
await sutProvider.GetDependency<IServiceAccountRepository>().DidNotReceiveWithAnyArgs()
.GetServiceAccountCountByOrganizationIdAsync(default);
}
[Theory, BitAutoData]
public async Task CountNewServiceAccountSlotsRequiredAsync_WithNonExistentOrganizationId_ThrowsNotFound(
Guid organizationId, int serviceAccountsToAdd,

View File

@ -90,12 +90,12 @@ public class ScimApplicationFactory : WebApplicationFactoryBase<Startup>
public async Task<HttpContext> GroupsPostAsync(Guid organizationId, ScimGroupRequestModel model)
{
return await Server.PostAsync($"/v2/{organizationId}/groups", GetStringContent(model), httpContext => httpContext.Request.Headers.Add(HeaderNames.UserAgent, "Okta"));
return await Server.PostAsync($"/v2/{organizationId}/groups", GetStringContent(model), httpContext => httpContext.Request.Headers.Append(HeaderNames.UserAgent, "Okta"));
}
public async Task<HttpContext> GroupsPutAsync(Guid organizationId, Guid id, ScimGroupRequestModel model)
{
return await Server.PutAsync($"/v2/{organizationId}/groups/{id}", GetStringContent(model), httpContext => httpContext.Request.Headers.Add(HeaderNames.UserAgent, "Okta"));
return await Server.PutAsync($"/v2/{organizationId}/groups/{id}", GetStringContent(model), httpContext => httpContext.Request.Headers.Append(HeaderNames.UserAgent, "Okta"));
}
public async Task<HttpContext> GroupsPatchAsync(Guid organizationId, Guid id, ScimPatchModel model)

View File

@ -9,7 +9,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="6.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />

View File

@ -1,6 +1,6 @@
{
"sdk": {
"version": "6.0.100",
"version": "8.0.100",
"rollForward": "latestFeature"
}
}

View File

@ -3,19 +3,15 @@ using Bit.Identity.IdentityServer;
using Bit.Infrastructure.Dapper.Auth.Repositories;
using Duende.IdentityServer.Models;
using Duende.IdentityServer.Stores;
using Microsoft.Extensions.Logging.Abstractions;
using StackExchange.Redis;
namespace Bit.MicroBenchmarks.Identity.IdentityServer;
[MemoryDiagnoser]
public class RedisPersistedGrantStoreTests
public class PersistedGrantStoreTests
{
const string SQL = nameof(SQL);
const string Redis = nameof(Redis);
const string Cosmos = nameof(Cosmos);
private readonly IPersistedGrantStore _redisGrantStore;
private readonly IPersistedGrantStore _sqlGrantStore;
private readonly IPersistedGrantStore _cosmosGrantStore;
private readonly PersistedGrant _updateGrant;
@ -39,14 +35,8 @@ public class RedisPersistedGrantStoreTests
// 15) "ClientId"
// 16) "web"
public RedisPersistedGrantStoreTests()
public PersistedGrantStoreTests()
{
_redisGrantStore = new RedisPersistedGrantStore(
ConnectionMultiplexer.Connect("localhost"),
NullLogger<RedisPersistedGrantStore>.Instance,
new InMemoryPersistedGrantStore()
);
var sqlConnectionString = "YOUR CONNECTION STRING HERE";
_sqlGrantStore = new PersistedGrantStore(
new GrantRepository(
@ -78,17 +68,13 @@ public class RedisPersistedGrantStoreTests
};
}
[Params(Redis, SQL, Cosmos)]
[Params(SQL, Cosmos)]
public string StoreType { get; set; } = null!;
[GlobalSetup]
public void Setup()
{
if (StoreType == Redis)
{
_grantStore = _redisGrantStore;
}
else if (StoreType == SQL)
if (StoreType == SQL)
{
_grantStore = _sqlGrantStore;
}

View File

@ -2,13 +2,12 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.13.11" />
<PackageReference Include="BenchmarkDotNet" Version="0.13.12" />
</ItemGroup>
<ItemGroup>

View File

@ -43,7 +43,7 @@ export const options = {
},
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<200"],
http_req_duration: ["p(95)<350"],
},
};

View File

@ -44,7 +44,7 @@ export const options = {
},
thresholds: {
http_req_failed: ["rate<0.01"],
http_req_duration: ["p(95)<300"],
http_req_duration: ["p(95)<400"],
},
};

View File

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-Admin</UserSecretsId>
@ -24,8 +24,4 @@
</When>
</Choose>
<ItemGroup>
<PackageReference Include="Microsoft.Azure.Cosmos" Version="3.26.1" />
</ItemGroup>
</Project>

View File

@ -3,13 +3,5 @@
public class AdminSettings
{
public virtual string Admins { get; set; }
public virtual CloudflareSettings Cloudflare { get; set; }
public int? DeleteTrashDaysAgo { get; set; }
public class CloudflareSettings
{
public string ZoneId { get; set; }
public string AuthEmail { get; set; }
public string AuthKey { get; set; }
}
}

View File

@ -1,94 +0,0 @@
using Bit.Admin.Models;
using Bit.Admin.Utilities;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Azure.Cosmos;
using Microsoft.Azure.Cosmos.Linq;
using Serilog.Events;
namespace Bit.Admin.Controllers;
[Authorize]
[SelfHosted(NotSelfHostedOnly = true)]
[RequirePermission(Enums.Permission.Logs_View)]
public class LogsController : Controller
{
private const string Database = "Diagnostics";
private const string Container = "Logs";
private readonly GlobalSettings _globalSettings;
public LogsController(GlobalSettings globalSettings)
{
_globalSettings = globalSettings;
}
public async Task<IActionResult> Index(string cursor = null, int count = 50,
LogEventLevel? level = null, string project = null, DateTime? start = null, DateTime? end = null)
{
using (var client = new CosmosClient(_globalSettings.DocumentDb.Uri,
_globalSettings.DocumentDb.Key))
{
var cosmosContainer = client.GetContainer(Database, Container);
var query = cosmosContainer.GetItemLinqQueryable<LogModel>(
requestOptions: new QueryRequestOptions()
{
MaxItemCount = count
},
continuationToken: cursor
).AsQueryable();
if (level.HasValue)
{
query = query.Where(l => l.Level == level.Value.ToString());
}
if (!string.IsNullOrWhiteSpace(project))
{
query = query.Where(l => l.Properties != null && l.Properties["Project"] == (object)project);
}
if (start.HasValue)
{
query = query.Where(l => l.Timestamp >= start.Value);
}
if (end.HasValue)
{
query = query.Where(l => l.Timestamp <= end.Value);
}
var feedIterator = query.OrderByDescending(l => l.Timestamp).ToFeedIterator();
var response = await feedIterator.ReadNextAsync();
return View(new LogsModel
{
Level = level,
Project = project,
Start = start,
End = end,
Items = response.ToList(),
Count = count,
Cursor = cursor,
NextCursor = response.ContinuationToken
});
}
}
public async Task<IActionResult> View(Guid id)
{
using (var client = new CosmosClient(_globalSettings.DocumentDb.Uri,
_globalSettings.DocumentDb.Key))
{
var cosmosContainer = client.GetContainer(Database, Container);
var query = cosmosContainer.GetItemLinqQueryable<LogDetailsModel>()
.AsQueryable()
.Where(l => l.Id == id.ToString());
var response = await query.ToFeedIterator().ReadNextAsync();
if (response == null || response.Count == 0)
{
return RedirectToAction("Index");
}
return View(response.First());
}
}
}

View File

@ -3,7 +3,9 @@ using Bit.Admin.Models;
using Bit.Admin.Services;
using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -48,6 +50,9 @@ public class OrganizationsController : Controller
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
public OrganizationsController(
IOrganizationService organizationService,
@ -71,7 +76,10 @@ public class OrganizationsController : Controller
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository)
IServiceAccountRepository serviceAccountRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand)
{
_organizationService = organizationService;
_organizationRepository = organizationRepository;
@ -95,6 +103,9 @@ public class OrganizationsController : Controller
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_serviceAccountRepository = serviceAccountRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
}
[RequirePermission(Permission.Org_List_View)]
@ -202,7 +213,6 @@ public class OrganizationsController : Controller
var organization = await GetOrganization(id, model);
if (organization.UseSecretsManager &&
!organization.SecretsManagerBeta &&
!StaticStore.GetPlan(organization.PlanType).SupportsSecretsManager)
{
throw new BadRequestException("Plan does not support Secrets Manager");
@ -286,6 +296,38 @@ public class OrganizationsController : Controller
return Json(null);
}
[HttpPost]
[RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> UnlinkOrganizationFromProviderAsync(Guid id)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization is null)
{
return RedirectToAction("Index");
}
var provider = await _providerRepository.GetByOrganizationIdAsync(id);
if (provider is null)
{
return RedirectToAction("Edit", new { id });
}
var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(id);
if (providerOrganization is null)
{
return RedirectToAction("Edit", new { id });
}
await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider(
provider,
providerOrganization,
organization);
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
return Json(null);
}
private async Task<Organization> GetOrganization(Guid id, OrganizationEditModel model)
{
var organization = await _organizationRepository.GetByIdAsync(id);
@ -320,7 +362,6 @@ public class OrganizationsController : Controller
organization.UseTotp = model.UseTotp;
organization.UsersGetPremium = model.UsersGetPremium;
organization.UseSecretsManager = model.UseSecretsManager;
organization.SecretsManagerBeta = model.SecretsManagerBeta;
//secrets
organization.SmSeats = model.SmSeats;

View File

@ -0,0 +1,67 @@
using Bit.Admin.Enums;
using Bit.Admin.Utilities;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Commands;
using Bit.Core.Repositories;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Admin.Controllers;
[Authorize]
[SelfHosted(NotSelfHostedOnly = true)]
public class ProviderOrganizationsController : Controller
{
private readonly IProviderRepository _providerRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
public ProviderOrganizationsController(IProviderRepository providerRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IOrganizationRepository organizationRepository,
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand)
{
_providerRepository = providerRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_organizationRepository = organizationRepository;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
}
[HttpPost]
[RequirePermission(Permission.Provider_Edit)]
public async Task<IActionResult> DeleteAsync(Guid providerId, Guid id)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider is null)
{
return RedirectToAction("Index", "Providers");
}
var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id);
if (providerOrganization is null)
{
return RedirectToAction("View", "Providers", new { id = providerId });
}
var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
if (organization == null)
{
return RedirectToAction("View", "Providers", new { id = providerId });
}
await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider(
provider,
providerOrganization,
organization);
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
return Json(null);
}
}

View File

@ -27,7 +27,7 @@ public class UsersController : Controller
private readonly IFeatureService _featureService;
private bool UseFlexibleCollections =>
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
public UsersController(
IUserRepository userRepository,

View File

@ -1,4 +1,4 @@
FROM mcr.microsoft.com/dotnet/aspnet:6.0
FROM mcr.microsoft.com/dotnet/aspnet:8.0
LABEL com.bitwarden.product="bitwarden"

View File

@ -47,7 +47,5 @@ public enum Permission
Tools_GenerateLicenseFile,
Tools_ManageTaxRates,
Tools_ManageStripeSubscriptions,
Tools_CreateEditTransaction,
Logs_View
Tools_CreateEditTransaction
}

View File

@ -1,83 +0,0 @@
using Amazon;
using Amazon.SQS;
using Amazon.SQS.Model;
using Bit.Core.Settings;
using Microsoft.Extensions.Options;
namespace Bit.Admin.HostedServices;
public class AmazonSqsBlockIpHostedService : BlockIpHostedService
{
private AmazonSQSClient _client;
public AmazonSqsBlockIpHostedService(
ILogger<AmazonSqsBlockIpHostedService> logger,
IOptions<AdminSettings> adminSettings,
GlobalSettings globalSettings)
: base(logger, adminSettings, globalSettings)
{ }
public override void Dispose()
{
_client?.Dispose();
}
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
_client = new AmazonSQSClient(_globalSettings.Amazon.AccessKeyId,
_globalSettings.Amazon.AccessKeySecret, RegionEndpoint.GetBySystemName(_globalSettings.Amazon.Region));
var blockIpQueue = await _client.GetQueueUrlAsync("block-ip", cancellationToken);
var blockIpQueueUrl = blockIpQueue.QueueUrl;
var unblockIpQueue = await _client.GetQueueUrlAsync("unblock-ip", cancellationToken);
var unblockIpQueueUrl = unblockIpQueue.QueueUrl;
while (!cancellationToken.IsCancellationRequested)
{
var blockMessageResponse = await _client.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = blockIpQueueUrl,
MaxNumberOfMessages = 10,
WaitTimeSeconds = 15
}, cancellationToken);
if (blockMessageResponse.Messages.Any())
{
foreach (var message in blockMessageResponse.Messages)
{
try
{
await BlockIpAsync(message.Body, cancellationToken);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to block IP.");
}
await _client.DeleteMessageAsync(blockIpQueueUrl, message.ReceiptHandle, cancellationToken);
}
}
var unblockMessageResponse = await _client.ReceiveMessageAsync(new ReceiveMessageRequest
{
QueueUrl = unblockIpQueueUrl,
MaxNumberOfMessages = 10,
WaitTimeSeconds = 15
}, cancellationToken);
if (unblockMessageResponse.Messages.Any())
{
foreach (var message in unblockMessageResponse.Messages)
{
try
{
await UnblockIpAsync(message.Body, cancellationToken);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to unblock IP.");
}
await _client.DeleteMessageAsync(unblockIpQueueUrl, message.ReceiptHandle, cancellationToken);
}
}
await Task.Delay(TimeSpan.FromSeconds(15));
}
}
}

View File

@ -1,63 +0,0 @@
using Azure.Storage.Queues;
using Bit.Core.Settings;
using Microsoft.Extensions.Options;
namespace Bit.Admin.HostedServices;
public class AzureQueueBlockIpHostedService : BlockIpHostedService
{
private QueueClient _blockIpQueueClient;
private QueueClient _unblockIpQueueClient;
public AzureQueueBlockIpHostedService(
ILogger<AzureQueueBlockIpHostedService> logger,
IOptions<AdminSettings> adminSettings,
GlobalSettings globalSettings)
: base(logger, adminSettings, globalSettings)
{ }
protected override async Task ExecuteAsync(CancellationToken cancellationToken)
{
_blockIpQueueClient = new QueueClient(_globalSettings.Storage.ConnectionString, "blockip");
_unblockIpQueueClient = new QueueClient(_globalSettings.Storage.ConnectionString, "unblockip");
while (!cancellationToken.IsCancellationRequested)
{
var blockMessages = await _blockIpQueueClient.ReceiveMessagesAsync(maxMessages: 32);
if (blockMessages.Value?.Any() ?? false)
{
foreach (var message in blockMessages.Value)
{
try
{
await BlockIpAsync(message.MessageText, cancellationToken);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to block IP.");
}
await _blockIpQueueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt);
}
}
var unblockMessages = await _unblockIpQueueClient.ReceiveMessagesAsync(maxMessages: 32);
if (unblockMessages.Value?.Any() ?? false)
{
foreach (var message in unblockMessages.Value)
{
try
{
await UnblockIpAsync(message.MessageText, cancellationToken);
}
catch (Exception e)
{
_logger.LogError(e, "Failed to unblock IP.");
}
await _unblockIpQueueClient.DeleteMessageAsync(message.MessageId, message.PopReceipt);
}
}
await Task.Delay(TimeSpan.FromSeconds(15));
}
}
}

View File

@ -67,14 +67,14 @@ public class AzureQueueMailHostedService : IHostedService
if (root.ValueKind == JsonValueKind.Array)
{
foreach (var mailQueueMessage in root.ToObject<List<MailQueueMessage>>())
foreach (var mailQueueMessage in root.Deserialize<List<MailQueueMessage>>())
{
await _mailService.SendEnqueuedMailMessageAsync(mailQueueMessage);
}
}
else if (root.ValueKind == JsonValueKind.Object)
{
var mailQueueMessage = root.ToObject<MailQueueMessage>();
var mailQueueMessage = root.Deserialize<MailQueueMessage>();
await _mailService.SendEnqueuedMailMessageAsync(mailQueueMessage);
}
}

View File

@ -1,164 +0,0 @@
using Bit.Core.Settings;
using Microsoft.Extensions.Options;
namespace Bit.Admin.HostedServices;
public abstract class BlockIpHostedService : IHostedService, IDisposable
{
protected readonly ILogger<BlockIpHostedService> _logger;
protected readonly GlobalSettings _globalSettings;
private readonly AdminSettings _adminSettings;
private Task _executingTask;
private CancellationTokenSource _cts;
private HttpClient _httpClient = new HttpClient();
public BlockIpHostedService(
ILogger<BlockIpHostedService> logger,
IOptions<AdminSettings> adminSettings,
GlobalSettings globalSettings)
{
_logger = logger;
_globalSettings = globalSettings;
_adminSettings = adminSettings?.Value;
}
public Task StartAsync(CancellationToken cancellationToken)
{
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_executingTask = ExecuteAsync(_cts.Token);
return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask;
}
public async Task StopAsync(CancellationToken cancellationToken)
{
if (_executingTask == null)
{
return;
}
_cts.Cancel();
await Task.WhenAny(_executingTask, Task.Delay(-1, cancellationToken));
cancellationToken.ThrowIfCancellationRequested();
}
public virtual void Dispose()
{ }
protected abstract Task ExecuteAsync(CancellationToken cancellationToken);
protected async Task BlockIpAsync(string message, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage();
request.Headers.Accept.Clear();
request.Headers.Add("X-Auth-Email", _adminSettings.Cloudflare.AuthEmail);
request.Headers.Add("X-Auth-Key", _adminSettings.Cloudflare.AuthKey);
request.Method = HttpMethod.Post;
request.RequestUri = new Uri("https://api.cloudflare.com/" +
$"client/v4/zones/{_adminSettings.Cloudflare.ZoneId}/firewall/access_rules/rules");
request.Content = JsonContent.Create(new
{
mode = "block",
configuration = new
{
target = "ip",
value = message
},
notes = $"Rate limit abuse on {DateTime.UtcNow.ToString()}."
});
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return;
}
var accessRuleResponse = await response.Content.ReadFromJsonAsync<AccessRuleResponse>(cancellationToken: cancellationToken);
if (!accessRuleResponse.Success)
{
return;
}
// TODO: Send `accessRuleResponse.Result?.Id` message to unblock queue
}
protected async Task UnblockIpAsync(string message, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
if (message.Contains(".") || message.Contains(":"))
{
// IP address messages
var request = new HttpRequestMessage();
request.Headers.Accept.Clear();
request.Headers.Add("X-Auth-Email", _adminSettings.Cloudflare.AuthEmail);
request.Headers.Add("X-Auth-Key", _adminSettings.Cloudflare.AuthKey);
request.Method = HttpMethod.Get;
request.RequestUri = new Uri("https://api.cloudflare.com/" +
$"client/v4/zones/{_adminSettings.Cloudflare.ZoneId}/firewall/access_rules/rules?" +
$"configuration_target=ip&configuration_value={message}");
var response = await _httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return;
}
var listResponse = await response.Content.ReadFromJsonAsync<ListResponse>(cancellationToken: cancellationToken);
if (!listResponse.Success)
{
return;
}
foreach (var rule in listResponse.Result)
{
await DeleteAccessRuleAsync(rule.Id, cancellationToken);
}
}
else
{
// Rule Id messages
await DeleteAccessRuleAsync(message, cancellationToken);
}
}
protected async Task DeleteAccessRuleAsync(string ruleId, CancellationToken cancellationToken)
{
var request = new HttpRequestMessage();
request.Headers.Accept.Clear();
request.Headers.Add("X-Auth-Email", _adminSettings.Cloudflare.AuthEmail);
request.Headers.Add("X-Auth-Key", _adminSettings.Cloudflare.AuthKey);
request.Method = HttpMethod.Delete;
request.RequestUri = new Uri("https://api.cloudflare.com/" +
$"client/v4/zones/{_adminSettings.Cloudflare.ZoneId}/firewall/access_rules/rules/{ruleId}");
await _httpClient.SendAsync(request, cancellationToken);
}
public class ListResponse
{
public bool Success { get; set; }
public List<AccessRuleResultResponse> Result { get; set; }
}
public class AccessRuleResponse
{
public bool Success { get; set; }
public AccessRuleResultResponse Result { get; set; }
}
public class AccessRuleResultResponse
{
public string Id { get; set; }
public string Notes { get; set; }
public ConfigurationResponse Configuration { get; set; }
public class ConfigurationResponse
{
public string Target { get; set; }
public string Value { get; set; }
}
}
}

View File

@ -1,54 +0,0 @@
using Microsoft.Azure.Documents;
using Newtonsoft.Json.Linq;
namespace Bit.Admin.Models;
public class LogModel : Resource
{
public long EventIdHash { get; set; }
public string Level { get; set; }
public string Message { get; set; }
public string MessageTruncated => Message.Length > 200 ? $"{Message.Substring(0, 200)}..." : Message;
public string MessageTemplate { get; set; }
public IDictionary<string, object> Properties { get; set; }
public string Project => Properties?.ContainsKey("Project") ?? false ? Properties["Project"].ToString() : null;
}
public class LogDetailsModel : LogModel
{
public JObject Exception { get; set; }
public string ExceptionToString(JObject e)
{
if (e == null)
{
return null;
}
var val = string.Empty;
if (e["Message"] != null && e["Message"].ToObject<string>() != null)
{
val += "Message:\n";
val += e["Message"] + "\n";
}
if (e["StackTrace"] != null && e["StackTrace"].ToObject<string>() != null)
{
val += "\nStack Trace:\n";
val += e["StackTrace"];
}
else if (e["StackTraceString"] != null && e["StackTraceString"].ToObject<string>() != null)
{
val += "\nStack Trace String:\n";
val += e["StackTraceString"];
}
if (e["InnerException"] != null && e["InnerException"].ToObject<JObject>() != null)
{
val += "\n\n=== Inner Exception ===\n\n";
val += ExceptionToString(e["InnerException"].ToObject<JObject>());
}
return val;
}
}

View File

@ -1,11 +0,0 @@
using Serilog.Events;
namespace Bit.Admin.Models;
public class LogsModel : CursorPagedModel<LogModel>
{
public LogEventLevel? Level { get; set; }
public string Project { get; set; }
public DateTime? Start { get; set; }
public DateTime? End { get; set; }
}

View File

@ -70,7 +70,6 @@ public class OrganizationEditModel : OrganizationViewModel
MaxAutoscaleSmSeats = org.MaxAutoscaleSmSeats;
SmServiceAccounts = org.SmServiceAccounts;
MaxAutoscaleSmServiceAccounts = org.MaxAutoscaleSmServiceAccounts;
SecretsManagerBeta = org.SecretsManagerBeta;
}
public BillingInfo BillingInfo { get; set; }
@ -150,8 +149,6 @@ public class OrganizationEditModel : OrganizationViewModel
public int? SmServiceAccounts { get; set; }
[Display(Name = "Max Autoscale Service Accounts")]
public int? MaxAutoscaleSmServiceAccounts { get; set; }
[Display(Name = "Secrets Manager Beta")]
public bool SecretsManagerBeta { get; set; }
/**
* Creates a Plan[] object for use in Javascript
@ -210,7 +207,6 @@ public class OrganizationEditModel : OrganizationViewModel
existingOrganization.MaxAutoscaleSmSeats = MaxAutoscaleSmSeats;
existingOrganization.SmServiceAccounts = SmServiceAccounts;
existingOrganization.MaxAutoscaleSmServiceAccounts = MaxAutoscaleSmServiceAccounts;
existingOrganization.SecretsManagerBeta = SecretsManagerBeta;
return existingOrganization;
}
}

View File

@ -9,6 +9,7 @@ using Stripe;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Bit.Admin.Services;
using Bit.Core.Billing.Extensions;
#if !OSS
using Bit.Commercial.Core.Utilities;
@ -87,6 +88,7 @@ public class Startup
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.AddScoped<IAccessControlService, AccessControlService>();
services.AddBillingCommands();
#if OSS
services.AddOosServices();
@ -116,14 +118,6 @@ public class Startup
}
else
{
if (CoreHelpers.SettingHasValue(globalSettings.Storage.ConnectionString))
{
services.AddHostedService<HostedServices.AzureQueueBlockIpHostedService>();
}
else if (CoreHelpers.SettingHasValue(globalSettings.Amazon?.AccessKeySecret))
{
services.AddHostedService<HostedServices.AmazonSqsBlockIpHostedService>();
}
if (CoreHelpers.SettingHasValue(globalSettings.Mail.ConnectionString))
{
services.AddHostedService<HostedServices.AzureQueueMailHostedService>();

View File

@ -47,8 +47,7 @@ public static class RolePermissionMapping
Permission.Tools_PromoteAdmin,
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Logs_View
Permission.Tools_ManageStripeSubscriptions
}
},
{ "admin", new List<Permission>
@ -94,8 +93,7 @@ public static class RolePermissionMapping
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction,
Permission.Logs_View
Permission.Tools_CreateEditTransaction
}
},
{ "cs", new List<Permission>
@ -123,8 +121,7 @@ public static class RolePermissionMapping
Permission.Org_Billing_View,
Permission.Org_Billing_LaunchGateway,
Permission.Provider_List_View,
Permission.Provider_View,
Permission.Logs_View
Permission.Provider_View
}
},
{ "billing", new List<Permission>
@ -163,8 +160,7 @@ public static class RolePermissionMapping
Permission.Tools_GenerateLicenseFile,
Permission.Tools_ManageTaxRates,
Permission.Tools_ManageStripeSubscriptions,
Permission.Tools_CreateEditTransaction,
Permission.Logs_View
Permission.Tools_CreateEditTransaction
}
},
{ "sales", new List<Permission>
@ -193,8 +189,7 @@ public static class RolePermissionMapping
Permission.Provider_Create,
Permission.Provider_Edit,
Permission.Provider_View,
Permission.Provider_ResendEmailInvite,
Permission.Logs_View
Permission.Provider_ResendEmailInvite
}
},
};

View File

@ -1,91 +0,0 @@
@model LogsModel
@{
ViewData["Title"] = "Logs";
}
<h1>Logs</h1>
<p>Current UTC time: @DateTime.UtcNow.ToString()</p>
<form class="form-inline mb-2" method="get">
<label class="sr-only" asp-for="Level">Level</label>
<select class="form-control mb-2 mr-2" asp-for="Level" name="level"
asp-items="Html.GetEnumSelectList<Serilog.Events.LogEventLevel>()">
<option value="">-- Level --</option>
</select>
<label class="sr-only" asp-for="Project">Project</label>
<select class="form-control mb-2 mr-2" asp-for="Project" name="project">
<option asp-selected="string.IsNullOrWhiteSpace(Model.Project)" value="">-- Project --</option>
<option asp-selected="@(Model.Project == "Admin")" value="Admin">Admin</option>
<option asp-selected="@(Model.Project == "Api")" value="Api">Api</option>
<option asp-selected="@(Model.Project == "Billing")" value="Billing">Billing</option>
<option asp-selected="@(Model.Project == "Events")" value="Events">Events</option>
<option asp-selected="@(Model.Project == "Events Processor")" value="Events Processor">Events Processor</option>
<option asp-selected="@(Model.Project == "Identity")" value="Identity">Identity</option>
<option asp-selected="@(Model.Project == "Notifications")" value="Notifications">Notifications</option>
<option asp-selected="@(Model.Project == "Icons")" value="Icons">Icons</option>
<option asp-selected="@(Model.Project == "SSO")" value="SSO">SSO</option>
<option asp-selected="@(Model.Project == "Scim")" value="Scim">SCIM</option>
</select>
<input class="form-control mb-2 mr-2" type="datetime-local" asp-for="Start" name="start" placeholder="Start Date">
<input class="form-control mb-2 mr-2" type="datetime-local" asp-for="End" name="end" placeholder="End Date">
<button type="submit" class="btn btn-primary mb-2" title="Search"><i class="fa fa-search"></i> Search</button>
</form>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th style="width: 50px;">&nbsp;</th>
<th style="width: 210px;">Timestamp</th>
<th style="width: 105px;">Project</th>
<th style="width: 125px;">Level</th>
<th>Message</th>
</tr>
</thead>
<tbody>
@if(!Model.Items.Any())
{
<tr>
<td colspan="5">No results to list.</td>
</tr>
}
else
{
@foreach(var log in Model.Items)
{
<tr>
<td>
<a asp-action="View" asp-route-id="@log.Id" title="View">
<i class="fa fa-file-text-o fa-lg"></i>
</a>
</td>
<td>@log.Timestamp.ToString()</td>
<td>@(string.IsNullOrWhiteSpace(log.Project) ? "-" : log.Project)</td>
<td>@log.Level</td>
<td>@log.MessageTruncated</td>
</tr>
}
}
</tbody>
</table>
</div>
<nav>
<ul class="pagination">
@if(string.IsNullOrWhiteSpace(Model.NextCursor))
{
<li class="page-item disabled">
<a class="page-link" href="#" tabindex="-1">Next</a>
</li>
}
else
{
<li class="page-item">
<a class="page-link" asp-action="Index" asp-route-cursor="@Model.NextCursor"
asp-route-count="@Model.Count" asp-route-project="@Model.Project"
asp-route-level="@Model.Level">Next</a>
</li>
}
</ul>
</nav>

View File

@ -1,42 +0,0 @@
@model LogDetailsModel
@{
ViewData["Title"] = "Log: " + Model.Id;
}
<h1>Log <small>@Model.Id</small></h1>
<h2>Information</h2>
<dl class="row">
<dt class="col-sm-4 col-lg-3">Id</dt>
<dd class="col-sm-8 col-lg-9"><code>@Model.Id</code></dd>
<dt class="col-sm-4 col-lg-3">Event Id Hash</dt>
<dd class="col-sm-8 col-lg-9">@Model.EventIdHash</dd>
<dt class="col-sm-4 col-lg-3">Timestamp</dt>
<dd class="col-sm-8 col-lg-9">@Model.Timestamp.ToString()</dd>
<dt class="col-sm-4 col-lg-3">Level</dt>
<dd class="col-sm-8 col-lg-9">@Model.Level</dd>
</dl>
<h2>Message</h2>
<pre style="max-height: 500px;">@Model.Message</pre>
@if(Model.Exception != null)
{
<h2>Exception</h2>
<pre style="max-height: 500px;">@Model.ExceptionToString(Model.Exception)</pre>
}
@if(Model.Properties != null && Model.Properties.Count > 0)
{
<h2>Properties</h2>
<dl class="row">
@foreach(var prop in Model.Properties)
{
<dt class="col-sm-4 col-lg-3">@prop.Key</dt>
<dd class="col-sm-8 col-lg-9">@(prop.Value?.ToString() ?? "-")</dd>
}
</dl>
}

View File

@ -8,6 +8,7 @@
var canViewBillingInformation = AccessControlService.UserHasPermission(Permission.Org_BillingInformation_View);
var canInitiateTrial = AccessControlService.UserHasPermission(Permission.Org_InitiateTrial);
var canDelete = AccessControlService.UserHasPermission(Permission.Org_Delete);
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
}
@section Scripts {
@ -81,7 +82,7 @@
<div class="d-flex mt-4">
<button type="submit" class="btn btn-primary" form="edit-form">Save</button>
<div class="ml-auto d-flex">
@if (canInitiateTrial)
@if (canInitiateTrial && Model.Provider is null)
{
<button class="btn btn-secondary mr-2" type="button" id="teams-trial">
Teams Trial
@ -90,6 +91,15 @@
Enterprise Trial
</button>
}
@if (canUnlinkFromProvider && Model.Provider is not null)
{
<button
class="btn btn-outline-danger mr-2"
onclick="return unlinkProvider('@Model.Organization.Id');"
>
Unlink provider
</button>
}
@if (canDelete)
{
<form asp-action="Delete" asp-route-id="@Model.Organization.Id"

View File

@ -1,7 +1,15 @@
@using Bit.Core.AdminConsole.Enums.Provider
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Bit.Admin.Enums
@inject Bit.Admin.Services.IAccessControlService AccessControlService
@model ProviderViewModel
@{
var canUnlinkFromProvider = AccessControlService.UserHasPermission(Permission.Provider_Edit);
}
@await Html.PartialAsync("_ProviderScripts")
@await Html.PartialAsync("_ProviderOrganizationScripts")
<h2>Provider Organizations</h2>
<div class="row">
@ -32,26 +40,28 @@
}
else
{
@foreach (var org in Model.ProviderOrganizations)
@foreach (var providerOrganization in Model.ProviderOrganizations)
{
<tr>
<td class="align-middle">
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@org.OrganizationId">@org.OrganizationName</a>
<a asp-controller="Organizations" asp-action="Edit" asp-route-id="@providerOrganization.OrganizationId">@providerOrganization.OrganizationName</a>
</td>
<td>
@org.Status
@providerOrganization.Status
</td>
<td>
<div class="float-right">
@if (org.Status == OrganizationStatusType.Pending)
@if (canUnlinkFromProvider)
{
<a href="#" class="float-right" onclick="return resendOwnerInvite('@org.OrganizationId');">
<i class="fa fa-envelope-o fa-lg" title="Resend Setup Invite"></i>
<a href="#" class="text-danger float-right" onclick="return unlinkProvider('@Model.Provider.Id', '@providerOrganization.Id');">
Unlink provider
</a>
}
else
@if (providerOrganization.Status == OrganizationStatusType.Pending)
{
<i class="fa fa-envelope-o fa-lg text-secondary"></i>
<a href="#" class="float-right mr-3" onclick="return resendOwnerInvite('@providerOrganization.OrganizationId');">
Resend invitation
</a>
}
</div>
</td>

View File

@ -0,0 +1,21 @@
<script>
function unlinkProvider(providerId, id) {
if (confirm('Are you sure you want to unlink this organization from its provider?')) {
$.ajax({
type: "POST",
url: `@Url.Action("Delete", "ProviderOrganizations")?providerId=${providerId}&id=${id}`,
dataType: 'json',
contentType: false,
processData: false,
success: function (response) {
alert("Successfully unlinked provider");
window.location.href = `@Url.Action("Edit", "Providers")?id=${providerId}`;
},
error: function (response) {
alert("Error!");
}
});
}
return false;
}
</script>

View File

@ -1,4 +1,4 @@
@using Bit.Admin.Enums;
@using Bit.Admin.Enums;
@inject SignInManager<IdentityUser> SignInManager
@inject Bit.Core.Settings.GlobalSettings GlobalSettings
@ -8,7 +8,6 @@
var canViewUsers = AccessControlService.UserHasPermission(Permission.User_List_View);
var canViewOrgs = AccessControlService.UserHasPermission(Permission.Org_List_View);
var canViewProviders = AccessControlService.UserHasPermission(Permission.Provider_List_View);
var canViewLogs = AccessControlService.UserHasPermission(Permission.Logs_View);
var canChargeBraintree = AccessControlService.UserHasPermission(Permission.Tools_ChargeBrainTreeCustomer);
var canCreateTransaction = AccessControlService.UserHasPermission(Permission.Tools_CreateEditTransaction);
var canPromoteAdmin = AccessControlService.UserHasPermission(Permission.Tools_PromoteAdmin);
@ -121,12 +120,6 @@
</div>
</li>
}
@if (canViewLogs)
{
<li class="nav-item" active-controller="Logs">
<a class="nav-link" asp-controller="Logs" asp-action="Index">Logs</a>
</li>
}
}
}
@if (GlobalSettings.SelfHosted)

View File

@ -174,10 +174,6 @@
<input type="checkbox" class="form-check-input" asp-for="UseSecretsManager" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseSecretsManager"></label>
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="SecretsManagerBeta" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="SecretsManagerBeta"></label>
</div>
</div>
</div>
}
@ -217,7 +213,7 @@
@if (canViewPlan)
{
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager || Model.SecretsManagerBeta)">
<div id="organization-secrets-configuration" hidden="@(!Model.UseSecretsManager)">
<h2>Secrets Manager Configuration</h2>
<div class="row">
<div class="col-sm">
@ -276,20 +272,7 @@
<div class="col-sm">
<div class="form-group">
<label asp-for="BillingEmail"></label>
@if (Model.Provider?.Type == ProviderType.Reseller)
{
<input type="email" class="form-control" asp-for="BillingEmail" readonly="readonly">
}
else
{
<input
type="text"
class="form-control"
asp-for="BillingEmail"
readonly='@(!canEditBilling)'
pattern="@(@"[^@\s]+@[^@\s]+\.[^@\s]+")"
title="Email address must be in the format 'address@domain.com'.">
}
</div>
</div>
<div class="col-sm">

View File

@ -46,24 +46,8 @@
return;
}
// SM beta requires SM access
document.getElementById('@(nameof(Model.SecretsManagerBeta))').checked = false;
clearSecretsManagerConfiguration();
});
document.getElementById('@(nameof(Model.SecretsManagerBeta))').addEventListener('change', (event) => {
document.getElementById('organization-secrets-configuration').hidden = event.target.checked;
if (event.target.checked) {
// SM beta requires SM access
document.getElementById('@(nameof(Model.UseSecretsManager))').checked = true;
// SM Beta orgs do not have subscription limits
clearSecretsManagerConfiguration();
return;
}
setInitialSecretsManagerConfiguration();
});
})();
function togglePlanFeatures(planType) {
@ -113,6 +97,26 @@
}
}
function unlinkProvider(id) {
if (confirm('Are you sure you want to unlink this organization from its provider?')) {
$.ajax({
type: "POST",
url: `@Url.Action("UnlinkOrganizationFromProvider", "Organizations")?id=${id}`,
dataType: 'json',
contentType: false,
processData: false,
success: function (response) {
alert("Successfully unlinked provider");
window.location.href = `@Url.Action("Edit", "Organizations")?id=${id}`;
},
error: function (response) {
alert("Error!");
}
});
}
return false;
}
/***
* Set Secrets Manager values based on current usage (for migrating from SM beta or reinstating an old subscription)
*/

View File

@ -1,4 +1,4 @@
{
{
"globalSettings": {
"selfHosted": false,
"siteName": "Bitwarden",
@ -48,12 +48,7 @@
}
},
"adminSettings": {
"admins": "",
"cloudflare": {
"zoneId": "SECRET",
"authEmail": "SECRET",
"authKey": "SECRET"
}
"admins": ""
},
"braintree": {
"production": false,

View File

@ -3,7 +3,6 @@ using Bit.Api.AdminConsole.Models.Response;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Vault.AuthorizationHandlers.Groups;
using Bit.Core;
using Bit.Core.AdminConsole.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
@ -27,10 +26,8 @@ public class GroupsController : Controller
private readonly ICurrentContext _currentContext;
private readonly ICreateGroupCommand _createGroupCommand;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly IFeatureService _featureService;
private readonly IAuthorizationService _authorizationService;
private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
private readonly IApplicationCacheService _applicationCacheService;
public GroupsController(
IGroupRepository groupRepository,
@ -40,8 +37,8 @@ public class GroupsController : Controller
ICreateGroupCommand createGroupCommand,
IUpdateGroupCommand updateGroupCommand,
IDeleteGroupCommand deleteGroupCommand,
IFeatureService featureService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IApplicationCacheService applicationCacheService)
{
_groupRepository = groupRepository;
_groupService = groupService;
@ -50,8 +47,8 @@ public class GroupsController : Controller
_createGroupCommand = createGroupCommand;
_updateGroupCommand = updateGroupCommand;
_deleteGroupCommand = deleteGroupCommand;
_featureService = featureService;
_authorizationService = authorizationService;
_applicationCacheService = applicationCacheService;
}
[HttpGet("{id}")]
@ -81,7 +78,7 @@ public class GroupsController : Controller
[HttpGet("")]
public async Task<ListResponseModel<GroupDetailsResponseModel>> Get(Guid orgId)
{
if (UseFlexibleCollections)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await Get_vNext(orgId);
@ -128,7 +125,7 @@ public class GroupsController : Controller
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
var group = model.ToGroup(orgIdGuid);
await _createGroupCommand.CreateGroupAsync(group, organization, model.Collections?.Select(c => c.ToSelectionReadOnly()), model.Users);
await _createGroupCommand.CreateGroupAsync(group, organization, model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users);
return new GroupResponseModel(group);
}
@ -146,7 +143,7 @@ public class GroupsController : Controller
var orgIdGuid = new Guid(orgId);
var organization = await _organizationRepository.GetByIdAsync(orgIdGuid);
await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, model.Collections?.Select(c => c.ToSelectionReadOnly()), model.Users);
await _updateGroupCommand.UpdateGroupAsync(model.ToGroup(group), organization, model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), model.Users);
return new GroupResponseModel(group);
}
@ -217,4 +214,10 @@ public class GroupsController : Controller
var responses = groups.Select(g => new GroupDetailsResponseModel(g.Item1, g.Item2));
return new ListResponseModel<GroupDetailsResponseModel>(responses);
}
private async Task<bool> FlexibleCollectionsIsEnabledAsync(Guid organizationId)
{
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
return organizationAbility?.FlexibleCollections ?? false;
}
}

View File

@ -4,7 +4,6 @@ using Bit.Api.Models.Request.Organizations;
using Bit.Api.Models.Response;
using Bit.Api.Utilities;
using Bit.Api.Vault.AuthorizationHandlers.OrganizationUsers;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -13,6 +12,7 @@ using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
@ -39,10 +39,8 @@ public class OrganizationUsersController : Controller
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IAcceptOrgUserCommand _acceptOrgUserCommand;
private readonly IFeatureService _featureService;
private readonly IAuthorizationService _authorizationService;
private bool UseFlexibleCollections => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
private readonly IApplicationCacheService _applicationCacheService;
public OrganizationUsersController(
IOrganizationRepository organizationRepository,
@ -57,8 +55,8 @@ public class OrganizationUsersController : Controller
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IAcceptOrgUserCommand acceptOrgUserCommand,
IFeatureService featureService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IApplicationCacheService applicationCacheService)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@ -72,8 +70,8 @@ public class OrganizationUsersController : Controller
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_acceptOrgUserCommand = acceptOrgUserCommand;
_featureService = featureService;
_authorizationService = authorizationService;
_applicationCacheService = applicationCacheService;
}
[HttpGet("{id}")]
@ -86,6 +84,15 @@ public class OrganizationUsersController : Controller
}
var response = new OrganizationUserDetailsResponseModel(organizationUser.Item1, organizationUser.Item2);
if (await FlexibleCollectionsIsEnabledAsync(organizationUser.Item1.OrganizationId))
{
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
response.Type = GetFlexibleCollectionsUserType(response.Type, response.Permissions);
// Set 'Edit/Delete Assigned Collections' custom permissions to false
response.Permissions.EditAssignedCollections = false;
response.Permissions.DeleteAssignedCollections = false;
}
if (includeGroups)
{
@ -98,9 +105,12 @@ public class OrganizationUsersController : Controller
[HttpGet("")]
public async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get(Guid orgId, bool includeGroups = false, bool includeCollections = false)
{
var authorized = UseFlexibleCollections
? (await _authorizationService.AuthorizeAsync(User, OrganizationUserOperations.ReadAll(orgId))).Succeeded
: await _currentContext.ViewAllCollections(orgId) ||
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
return await Get_vNext(orgId, includeGroups, includeCollections);
}
var authorized = await _currentContext.ViewAllCollections(orgId) ||
await _currentContext.ViewAssignedCollections(orgId) ||
await _currentContext.ManageGroups(orgId) ||
await _currentContext.ManageUsers(orgId);
@ -311,7 +321,7 @@ public class OrganizationUsersController : Controller
var userId = _userService.GetProperUserId(User);
await _organizationService.SaveUserAsync(model.ToOrganizationUser(organizationUser), userId.Value,
model.Collections?.Select(c => c.ToSelectionReadOnly()), model.Groups);
model.Collections?.Select(c => c.ToSelectionReadOnly()).ToList(), model.Groups);
}
[HttpPut("{id}/groups")]
@ -518,4 +528,71 @@ public class OrganizationUsersController : Controller
return new ListResponseModel<OrganizationUserBulkResponseModel>(result.Select(r =>
new OrganizationUserBulkResponseModel(r.Item1.Id, r.Item2)));
}
private async Task<bool> FlexibleCollectionsIsEnabledAsync(Guid organizationId)
{
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
return organizationAbility?.FlexibleCollections ?? false;
}
private async Task<ListResponseModel<OrganizationUserUserDetailsResponseModel>> Get_vNext(Guid orgId,
bool includeGroups = false, bool includeCollections = false)
{
var authorized = (await _authorizationService.AuthorizeAsync(
User, OrganizationUserOperations.ReadAll(orgId))).Succeeded;
if (!authorized)
{
throw new NotFoundException();
}
var organizationUsers = await _organizationUserRepository
.GetManyDetailsByOrganizationAsync(orgId, includeGroups, includeCollections);
var responseTasks = organizationUsers
.Select(async o =>
{
var orgUser = new OrganizationUserUserDetailsResponseModel(o,
await _userService.TwoFactorIsEnabledAsync(o));
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
orgUser.Type = GetFlexibleCollectionsUserType(orgUser.Type, orgUser.Permissions);
// Set 'Edit/Delete Assigned Collections' custom permissions to false
orgUser.Permissions.EditAssignedCollections = false;
orgUser.Permissions.DeleteAssignedCollections = false;
return orgUser;
});
var responses = await Task.WhenAll(responseTasks);
return new ListResponseModel<OrganizationUserUserDetailsResponseModel>(responses);
}
private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions)
{
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
if (type == OrganizationUserType.Custom)
{
if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) &&
permissions is
{
AccessEventLogs: false,
AccessImportExport: false,
AccessReports: false,
CreateNewCollections: false,
EditAnyCollection: false,
DeleteAnyCollection: false,
ManageGroups: false,
ManagePolicies: false,
ManageSso: false,
ManageUsers: false,
ManageResetPassword: false,
ManageScim: false
})
{
return OrganizationUserType.User;
}
}
return type;
}
}

View File

@ -14,10 +14,14 @@ using Bit.Core;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationApiKeys.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationCollectionEnhancements.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Repositories;
using Bit.Core.Auth.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
@ -27,6 +31,9 @@ using Bit.Core.OrganizationFeatures.OrganizationSubscriptions.Interface;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -40,7 +47,6 @@ public class OrganizationsController : Controller
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IPolicyRepository _policyRepository;
private readonly IProviderRepository _providerRepository;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly IPaymentService _paymentService;
@ -51,7 +57,6 @@ public class OrganizationsController : Controller
private readonly IRotateOrganizationApiKeyCommand _rotateOrganizationApiKeyCommand;
private readonly ICreateOrganizationApiKeyCommand _createOrganizationApiKeyCommand;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly IUpdateOrganizationLicenseCommand _updateOrganizationLicenseCommand;
private readonly ICloudGetOrganizationLicenseQuery _cloudGetOrganizationLicenseQuery;
private readonly IFeatureService _featureService;
private readonly GlobalSettings _globalSettings;
@ -59,12 +64,16 @@ public class OrganizationsController : Controller
private readonly IUpdateSecretsManagerSubscriptionCommand _updateSecretsManagerSubscriptionCommand;
private readonly IUpgradeOrganizationPlanCommand _upgradeOrganizationPlanCommand;
private readonly IAddSecretsManagerSubscriptionCommand _addSecretsManagerSubscriptionCommand;
private readonly IPushNotificationService _pushNotificationService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
private readonly IReferenceEventService _referenceEventService;
private readonly IOrganizationEnableCollectionEnhancementsCommand _organizationEnableCollectionEnhancementsCommand;
public OrganizationsController(
IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
IPolicyRepository policyRepository,
IProviderRepository providerRepository,
IOrganizationService organizationService,
IUserService userService,
IPaymentService paymentService,
@ -75,19 +84,22 @@ public class OrganizationsController : Controller
IRotateOrganizationApiKeyCommand rotateOrganizationApiKeyCommand,
ICreateOrganizationApiKeyCommand createOrganizationApiKeyCommand,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IUpdateOrganizationLicenseCommand updateOrganizationLicenseCommand,
ICloudGetOrganizationLicenseQuery cloudGetOrganizationLicenseQuery,
IFeatureService featureService,
GlobalSettings globalSettings,
ILicensingService licensingService,
IUpdateSecretsManagerSubscriptionCommand updateSecretsManagerSubscriptionCommand,
IUpgradeOrganizationPlanCommand upgradeOrganizationPlanCommand,
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand)
IAddSecretsManagerSubscriptionCommand addSecretsManagerSubscriptionCommand,
IPushNotificationService pushNotificationService,
ICancelSubscriptionCommand cancelSubscriptionCommand,
IGetSubscriptionQuery getSubscriptionQuery,
IReferenceEventService referenceEventService,
IOrganizationEnableCollectionEnhancementsCommand organizationEnableCollectionEnhancementsCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
_policyRepository = policyRepository;
_providerRepository = providerRepository;
_organizationService = organizationService;
_userService = userService;
_paymentService = paymentService;
@ -98,7 +110,6 @@ public class OrganizationsController : Controller
_rotateOrganizationApiKeyCommand = rotateOrganizationApiKeyCommand;
_createOrganizationApiKeyCommand = createOrganizationApiKeyCommand;
_organizationApiKeyRepository = organizationApiKeyRepository;
_updateOrganizationLicenseCommand = updateOrganizationLicenseCommand;
_cloudGetOrganizationLicenseQuery = cloudGetOrganizationLicenseQuery;
_featureService = featureService;
_globalSettings = globalSettings;
@ -106,6 +117,11 @@ public class OrganizationsController : Controller
_updateSecretsManagerSubscriptionCommand = updateSecretsManagerSubscriptionCommand;
_upgradeOrganizationPlanCommand = upgradeOrganizationPlanCommand;
_addSecretsManagerSubscriptionCommand = addSecretsManagerSubscriptionCommand;
_pushNotificationService = pushNotificationService;
_cancelSubscriptionCommand = cancelSubscriptionCommand;
_getSubscriptionQuery = getSubscriptionQuery;
_referenceEventService = referenceEventService;
_organizationEnableCollectionEnhancementsCommand = organizationEnableCollectionEnhancementsCommand;
}
[HttpGet("{id}")]
@ -245,6 +261,21 @@ public class OrganizationsController : Controller
return new OrganizationAutoEnrollStatusResponseModel(organization.Id, data?.AutoEnrollEnabled ?? false);
}
[HttpGet("{id}/risks-subscription-failure")]
public async Task<OrganizationRisksSubscriptionFailureResponseModel> RisksSubscriptionFailure(Guid id)
{
if (!await _currentContext.EditPaymentMethods(id))
{
return new OrganizationRisksSubscriptionFailureResponseModel(id, false);
}
var organization = await _organizationRepository.GetByIdAsync(id);
var risksSubscriptionFailure = await _paymentService.RisksSubscriptionFailure(organization);
return new OrganizationRisksSubscriptionFailureResponseModel(id, risksSubscriptionFailure);
}
[HttpPost("")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationResponseModel> Post([FromBody] OrganizationCreateRequestModel model)
@ -435,15 +466,48 @@ public class OrganizationsController : Controller
[HttpPost("{id}/cancel")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostCancel(string id)
public async Task PostCancel(Guid id, [FromBody] SubscriptionCancellationRequestModel request)
{
var orgIdGuid = new Guid(id);
if (!await _currentContext.EditSubscription(orgIdGuid))
if (!await _currentContext.EditSubscription(id))
{
throw new NotFoundException();
}
await _organizationService.CancelSubscriptionAsync(orgIdGuid);
var presentUserWithOffboardingSurvey =
_featureService.IsEnabled(FeatureFlagKeys.AC1607_PresentUsersWithOffboardingSurvey);
if (presentUserWithOffboardingSurvey)
{
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
var subscription = await _getSubscriptionQuery.GetSubscription(organization);
await _cancelSubscriptionCommand.CancelSubscription(subscription,
new OffboardingSurveyResponse
{
UserId = _currentContext.UserId!.Value,
Reason = request.Reason,
Feedback = request.Feedback
},
organization.IsExpired());
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(
ReferenceEventType.CancelSubscription,
organization,
_currentContext)
{
EndOfPeriod = organization.IsExpired()
});
}
else
{
await _organizationService.CancelSubscriptionAsync(id);
}
}
[HttpPost("{id}/reinstate")]
@ -775,7 +839,6 @@ public class OrganizationsController : Controller
}
[HttpPut("{id}/collection-management")]
[RequireFeature(FeatureFlagKeys.FlexibleCollections)]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<OrganizationResponseModel> PutCollectionManagement(Guid id, [FromBody] OrganizationCollectionManagementUpdateRequestModel model)
{
@ -790,7 +853,12 @@ public class OrganizationsController : Controller
throw new NotFoundException();
}
var v1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1, _currentContext);
if (!organization.FlexibleCollections)
{
throw new BadRequestException("Organization does not have collection enhancements enabled");
}
var v1Enabled = _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollectionsV1);
if (!v1Enabled)
{
@ -802,6 +870,40 @@ public class OrganizationsController : Controller
return new OrganizationResponseModel(organization);
}
/// <summary>
/// Migrates user, collection, and group data to the new Flexible Collections permissions scheme,
/// then sets organization.FlexibleCollections to true to enable these new features for the organization.
/// This is irreversible.
/// </summary>
/// <param name="organizationId"></param>
/// <exception cref="NotFoundException"></exception>
[HttpPost("{id}/enable-collection-enhancements")]
[RequireFeature(FeatureFlagKeys.FlexibleCollectionsMigration)]
public async Task EnableCollectionEnhancements(Guid id)
{
if (!await _currentContext.OrganizationOwner(id))
{
throw new NotFoundException();
}
var organization = await _organizationRepository.GetByIdAsync(id);
if (organization == null)
{
throw new NotFoundException();
}
await _organizationEnableCollectionEnhancementsCommand.EnableCollectionEnhancements(organization);
// Force a vault sync for all owners and admins of the organization so that changes show immediately
// Custom users are intentionally not handled as they are likely to be less impacted and we want to limit simultaneous syncs
var orgUsers = await _organizationUserRepository.GetManyByOrganizationAsync(id, null);
await Task.WhenAll(orgUsers
.Where(ou => ou.UserId.HasValue &&
ou.Status == OrganizationUserStatusType.Confirmed &&
ou.Type is OrganizationUserType.Admin or OrganizationUserType.Owner)
.Select(ou => _pushNotificationService.PushSyncOrganizationsAsync(ou.UserId.Value)));
}
private async Task TryGrantOwnerAccessToSecretsManagerAsync(Guid organizationId, Guid userId)
{
var organizationUser = await _organizationUserRepository.GetByOrganizationAsync(organizationId, userId);

View File

@ -1,10 +1,13 @@
using Bit.Api.AdminConsole.Models.Request.Providers;
using Bit.Api.AdminConsole.Models.Response.Providers;
using Bit.Api.Models.Response;
using Bit.Core.AdminConsole.Providers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Commands;
using Bit.Core.Context;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
@ -16,22 +19,33 @@ namespace Bit.Api.AdminConsole.Controllers;
[Authorize("Application")]
public class ProviderOrganizationsController : Controller
{
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IProviderService _providerService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly IOrganizationRepository _organizationRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly IRemoveOrganizationFromProviderCommand _removeOrganizationFromProviderCommand;
private readonly IRemovePaymentMethodCommand _removePaymentMethodCommand;
private readonly IUserService _userService;
public ProviderOrganizationsController(
ICurrentContext currentContext,
IOrganizationRepository organizationRepository,
IProviderOrganizationRepository providerOrganizationRepository,
IProviderRepository providerRepository,
IProviderService providerService,
IUserService userService,
ICurrentContext currentContext)
IRemoveOrganizationFromProviderCommand removeOrganizationFromProviderCommand,
IRemovePaymentMethodCommand removePaymentMethodCommand,
IUserService userService)
{
_providerOrganizationRepository = providerOrganizationRepository;
_providerService = providerService;
_userService = userService;
_currentContext = currentContext;
_organizationRepository = organizationRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_providerRepository = providerRepository;
_providerService = providerService;
_removeOrganizationFromProviderCommand = removeOrganizationFromProviderCommand;
_removePaymentMethodCommand = removePaymentMethodCommand;
_userService = userService;
}
[HttpGet("")]
@ -87,7 +101,17 @@ public class ProviderOrganizationsController : Controller
throw new NotFoundException();
}
var userId = _userService.GetProperUserId(User);
await _providerService.RemoveOrganizationAsync(providerId, id, userId.Value);
var provider = await _providerRepository.GetByIdAsync(providerId);
var providerOrganization = await _providerOrganizationRepository.GetByIdAsync(id);
var organization = await _organizationRepository.GetByIdAsync(providerOrganization.OrganizationId);
await _removeOrganizationFromProviderCommand.RemoveOrganizationFromProvider(
provider,
providerOrganization,
organization);
await _removePaymentMethodCommand.RemovePaymentMethod(organization);
}
}

View File

@ -31,7 +31,7 @@ public class OrganizationConnectionRequestModel<T> : OrganizationConnectionReque
try
{
ParsedConfig = model.Config.ToObject<T>(JsonHelpers.IgnoreCase);
ParsedConfig = model.Config.Deserialize<T>(JsonHelpers.IgnoreCase);
}
catch (JsonException)
{

View File

@ -46,6 +46,7 @@ public class OrganizationCreateRequestModel : IValidatableObject
public int? AdditionalServiceAccounts { get; set; }
[Required]
public bool UseSecretsManager { get; set; }
public bool IsFromSecretsManagerTrial { get; set; }
public virtual OrganizationSignup ToOrganizationSignup(User user)
{
@ -67,6 +68,7 @@ public class OrganizationCreateRequestModel : IValidatableObject
AdditionalSmSeats = AdditionalSmSeats.GetValueOrDefault(),
AdditionalServiceAccounts = AdditionalServiceAccounts.GetValueOrDefault(),
UseSecretsManager = UseSecretsManager,
IsFromSecretsManagerTrial = IsFromSecretsManagerTrial,
TaxInfo = new TaxInfo
{
TaxIdNumber = TaxIdNumber,

View File

@ -110,7 +110,6 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
CoreHelpers.ReadableBytesSize(organization.Storage.Value) : null;
StorageGb = organization.Storage.HasValue ?
Math.Round(organization.Storage.Value / 1073741824D, 2) : 0; // 1 GB
SecretsManagerBeta = organization.SecretsManagerBeta;
}
public OrganizationSubscriptionResponseModel(Organization organization, SubscriptionInfo subscription, bool hideSensitiveData)
@ -127,8 +126,6 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
Subscription.Items = null;
UpcomingInvoice.Amount = null;
}
SecretsManagerBeta = organization.SecretsManagerBeta;
}
public OrganizationSubscriptionResponseModel(Organization organization, OrganizationLicense license) :
@ -143,8 +140,6 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
license.Expires?.AddDays(-Constants
.OrganizationSelfHostSubscriptionGracePeriodDays);
}
SecretsManagerBeta = organization.SecretsManagerBeta;
}
public string StorageName { get; set; }
@ -162,6 +157,4 @@ public class OrganizationSubscriptionResponseModel : OrganizationResponseModel
/// Date when a self-hosted organization expires (includes grace period).
/// </summary>
public DateTime? Expiration { get; set; }
public bool SecretsManagerBeta { get; set; }
}

View File

@ -0,0 +1,17 @@
using Bit.Core.Models.Api;
namespace Bit.Api.AdminConsole.Models.Response.Organizations;
public class OrganizationRisksSubscriptionFailureResponseModel : ResponseModel
{
public Guid OrganizationId { get; }
public bool RisksSubscriptionFailure { get; }
public OrganizationRisksSubscriptionFailureResponseModel(
Guid organizationId,
bool risksSubscriptionFailure) : base("organizationRisksSubscriptionFailure")
{
OrganizationId = organizationId;
RisksSubscriptionFailure = risksSubscriptionFailure;
}
}

View File

@ -69,6 +69,37 @@ public class ProfileOrganizationResponseModel : ResponseModel
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
}
if (FlexibleCollections)
{
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
if (Type == OrganizationUserType.Custom)
{
if ((Permissions.EditAssignedCollections || Permissions.DeleteAssignedCollections) &&
Permissions is
{
AccessEventLogs: false,
AccessImportExport: false,
AccessReports: false,
CreateNewCollections: false,
EditAnyCollection: false,
DeleteAnyCollection: false,
ManageGroups: false,
ManagePolicies: false,
ManageSso: false,
ManageUsers: false,
ManageResetPassword: false,
ManageScim: false
})
{
organization.Type = OrganizationUserType.User;
}
}
// Set 'Edit/Delete Assigned Collections' custom permissions to false
Permissions.EditAssignedCollections = false;
Permissions.DeleteAssignedCollections = false;
}
}
public Guid Id { get; set; }

View File

@ -43,5 +43,8 @@ public class ProfileProviderOrganizationResponseModel : ProfileOrganizationRespo
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
PlanProductType = StaticStore.GetPlan(organization.PlanType).Product;
LimitCollectionCreationDeletion = organization.LimitCollectionCreationDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
FlexibleCollections = organization.FlexibleCollections;
}
}

View File

@ -110,8 +110,8 @@ public class GroupsController : Controller
public async Task<IActionResult> Post([FromBody] GroupCreateUpdateRequestModel model)
{
var group = model.ToGroup(_currentContext.OrganizationId.Value);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organization.FlexibleCollections)).ToList();
await _createGroupCommand.CreateGroupAsync(group, organization, associations);
var response = new GroupResponseModel(group, associations);
return new JsonResult(response);
@ -139,8 +139,8 @@ public class GroupsController : Controller
}
var updatedGroup = model.ToGroup(existingGroup);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
var organization = await _organizationRepository.GetByIdAsync(_currentContext.OrganizationId.Value);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(organization.FlexibleCollections)).ToList();
await _updateGroupCommand.UpdateGroupAsync(updatedGroup, organization, associations);
var response = new GroupResponseModel(updatedGroup, associations);
return new JsonResult(response);

View File

@ -5,7 +5,6 @@ using Bit.Api.Models.Public.Response;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Models.Business;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Authorization;
@ -23,6 +22,7 @@ public class MembersController : Controller
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IApplicationCacheService _applicationCacheService;
public MembersController(
IOrganizationUserRepository organizationUserRepository,
@ -30,7 +30,8 @@ public class MembersController : Controller
IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand)
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IApplicationCacheService applicationCacheService)
{
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
@ -38,6 +39,7 @@ public class MembersController : Controller
_userService = userService;
_currentContext = currentContext;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_applicationCacheService = applicationCacheService;
}
/// <summary>
@ -59,8 +61,9 @@ public class MembersController : Controller
{
return new NotFoundResult();
}
var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(orgUser.OrganizationId);
var response = new MemberResponseModel(orgUser, await _userService.TwoFactorIsEnabledAsync(orgUser),
userDetails.Item2);
userDetails.Item2, flexibleCollectionsIsEnabled);
return new JsonResult(response);
}
@ -99,9 +102,10 @@ public class MembersController : Controller
{
var users = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(
_currentContext.OrganizationId.Value);
var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value);
// TODO: Get all CollectionUser associations for the organization and marry them up here for the response.
var memberResponsesTasks = users.Select(async u => new MemberResponseModel(u,
await _userService.TwoFactorIsEnabledAsync(u), null));
await _userService.TwoFactorIsEnabledAsync(u), null, flexibleCollectionsIsEnabled));
var memberResponses = await Task.WhenAll(memberResponsesTasks);
var response = new ListResponseModel<MemberResponseModel>(memberResponses);
return new JsonResult(response);
@ -119,17 +123,11 @@ public class MembersController : Controller
[ProducesResponseType(typeof(ErrorResponseModel), (int)HttpStatusCode.BadRequest)]
public async Task<IActionResult> Post([FromBody] MemberCreateRequestModel model)
{
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
var invite = new OrganizationUserInvite
{
Emails = new List<string> { model.Email },
Type = model.Type.Value,
AccessAll = model.AccessAll.Value,
Collections = associations
};
var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList();
var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null,
model.Email, model.Type.Value, model.AccessAll.Value, model.ExternalId, associations, model.Groups);
var response = new MemberResponseModel(user, associations);
var response = new MemberResponseModel(user, associations, flexibleCollectionsIsEnabled);
return new JsonResult(response);
}
@ -154,18 +152,19 @@ public class MembersController : Controller
return new NotFoundResult();
}
var updatedUser = model.ToOrganizationUser(existingUser);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection());
var flexibleCollectionsIsEnabled = await FlexibleCollectionsIsEnabledAsync(_currentContext.OrganizationId.Value);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection(flexibleCollectionsIsEnabled)).ToList();
await _organizationService.SaveUserAsync(updatedUser, null, associations, model.Groups);
MemberResponseModel response = null;
if (existingUser.UserId.HasValue)
{
var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
response = new MemberResponseModel(existingUserDetails,
await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations);
await _userService.TwoFactorIsEnabledAsync(existingUserDetails), associations, flexibleCollectionsIsEnabled);
}
else
{
response = new MemberResponseModel(updatedUser, associations);
response = new MemberResponseModel(updatedUser, associations, flexibleCollectionsIsEnabled);
}
return new JsonResult(response);
}
@ -236,4 +235,10 @@ public class MembersController : Controller
await _organizationService.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id);
return new OkResult();
}
private async Task<bool> FlexibleCollectionsIsEnabledAsync(Guid organizationId)
{
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
return organizationAbility?.FlexibleCollections ?? false;
}
}

View File

@ -20,4 +20,9 @@ public abstract class AssociationWithPermissionsBaseModel
/// This prevents easy copy-and-paste of hidden items, however it may not completely prevent user access.
/// </summary>
public bool? HidePasswords { get; set; }
/// <summary>
/// When true, the manage permission allows a user to both edit the ciphers within a collection and edit the users/groups that are assigned to the collection.
/// This field will not affect behavior until your organization is using the latest collection enhancements (Releasing Q1, 2024)
/// </summary>
public bool? Manage { get; set; }
}

View File

@ -13,9 +13,9 @@ public abstract class GroupBaseModel
public string Name { get; set; }
/// <summary>
/// Determines if this group can access all collections within the organization, or only the associated
/// collections. If set to <c>true</c>, this option overrides any collection assignments.
/// collections. If set to <c>true</c>, this option overrides any collection assignments. If your organization is using
/// the latest collection enhancements, you will not be allowed to set this property to <c>true</c>.
/// </summary>
[Required]
public bool? AccessAll { get; set; }
/// <summary>
/// External identifier for reference or linking this group to another system, such as a user directory.

View File

@ -1,6 +1,7 @@
using System.ComponentModel.DataAnnotations;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Api.AdminConsole.Public.Models;
@ -9,42 +10,43 @@ public abstract class MemberBaseModel
{
public MemberBaseModel() { }
public MemberBaseModel(OrganizationUser user)
public MemberBaseModel(OrganizationUser user, bool flexibleCollectionsEnabled)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
Type = user.Type;
Type = flexibleCollectionsEnabled ? GetFlexibleCollectionsUserType(user.Type, user.GetPermissions()) : user.Type;
AccessAll = user.AccessAll;
ExternalId = user.ExternalId;
ResetPasswordEnrolled = user.ResetPasswordKey != null;
}
public MemberBaseModel(OrganizationUserUserDetails user)
public MemberBaseModel(OrganizationUserUserDetails user, bool flexibleCollectionsEnabled)
{
if (user == null)
{
throw new ArgumentNullException(nameof(user));
}
Type = user.Type;
Type = flexibleCollectionsEnabled ? GetFlexibleCollectionsUserType(user.Type, user.GetPermissions()) : user.Type;
AccessAll = user.AccessAll;
ExternalId = user.ExternalId;
ResetPasswordEnrolled = user.ResetPasswordKey != null;
}
/// <summary>
/// The member's type (or role) within the organization.
/// The member's type (or role) within the organization. If your organization has is using the latest collection enhancements,
/// you will not be allowed to assign the Manager role (OrganizationUserType = 3).
/// </summary>
[Required]
public OrganizationUserType? Type { get; set; }
/// <summary>
/// Determines if this member can access all collections within the organization, or only the associated
/// collections. If set to <c>true</c>, this option overrides any collection assignments.
/// collections. If set to <c>true</c>, this option overrides any collection assignments. If your organization is using
/// the latest collection enhancements, you will not be allowed to set this property to <c>true</c>.
/// </summary>
[Required]
public bool? AccessAll { get; set; }
/// <summary>
/// External identifier for reference or linking this member to another system, such as a user directory.
@ -57,4 +59,34 @@ public abstract class MemberBaseModel
/// </summary>
[Required]
public bool ResetPasswordEnrolled { get; set; }
// TODO: AC-2188 - Remove this method when the custom users with no other permissions than 'Edit/Delete Assigned Collections' are migrated
private OrganizationUserType GetFlexibleCollectionsUserType(OrganizationUserType type, Permissions permissions)
{
// Downgrade Custom users with no other permissions than 'Edit/Delete Assigned Collections' to User
if (type == OrganizationUserType.Custom)
{
if ((permissions.EditAssignedCollections || permissions.DeleteAssignedCollections) &&
permissions is
{
AccessEventLogs: false,
AccessImportExport: false,
AccessReports: false,
CreateNewCollections: false,
EditAnyCollection: false,
DeleteAnyCollection: false,
ManageGroups: false,
ManagePolicies: false,
ManageSso: false,
ManageUsers: false,
ManageResetPassword: false,
ManageScim: false
})
{
return OrganizationUserType.User;
}
}
return type;
}
}

View File

@ -1,16 +1,27 @@
using Bit.Core.Models.Data;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Public.Models.Request;
public class AssociationWithPermissionsRequestModel : AssociationWithPermissionsBaseModel
{
public CollectionAccessSelection ToCollectionAccessSelection()
public CollectionAccessSelection ToCollectionAccessSelection(bool migratedToFlexibleCollections)
{
return new CollectionAccessSelection
var collectionAccessSelection = new CollectionAccessSelection
{
Id = Id.Value,
ReadOnly = ReadOnly.Value,
HidePasswords = HidePasswords.GetValueOrDefault()
HidePasswords = HidePasswords.GetValueOrDefault(),
Manage = Manage.GetValueOrDefault()
};
// Throws if the org has not migrated to use FC but has passed in a Manage value in the request
if (!migratedToFlexibleCollections && Manage.GetValueOrDefault())
{
throw new BadRequestException(
"Your organization must be using the latest collection enhancements to use the Manage property.");
}
return collectionAccessSelection;
}
}

View File

@ -13,5 +13,6 @@ public class AssociationWithPermissionsResponseModel : AssociationWithPermission
Id = selection.Id;
ReadOnly = selection.ReadOnly;
HidePasswords = selection.HidePasswords;
Manage = selection.Manage;
}
}

View File

@ -12,8 +12,9 @@ namespace Bit.Api.AdminConsole.Public.Models.Response;
/// </summary>
public class MemberResponseModel : MemberBaseModel, IResponseModel
{
public MemberResponseModel(OrganizationUser user, IEnumerable<CollectionAccessSelection> collections)
: base(user)
public MemberResponseModel(OrganizationUser user, IEnumerable<CollectionAccessSelection> collections,
bool flexibleCollectionsEnabled)
: base(user, flexibleCollectionsEnabled)
{
if (user == null)
{
@ -28,8 +29,8 @@ public class MemberResponseModel : MemberBaseModel, IResponseModel
}
public MemberResponseModel(OrganizationUserUserDetails user, bool twoFactorEnabled,
IEnumerable<CollectionAccessSelection> collections)
: base(user)
IEnumerable<CollectionAccessSelection> collections, bool flexibleCollectionsEnabled)
: base(user, flexibleCollectionsEnabled)
{
if (user == null)
{

View File

@ -21,6 +21,9 @@ using Bit.Core.Auth.Services;
using Bit.Core.Auth.UserFeatures.UserKey;
using Bit.Core.Auth.UserFeatures.UserMasterPassword.Interfaces;
using Bit.Core.Auth.Utilities;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Models;
using Bit.Core.Billing.Queries;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
@ -32,6 +35,8 @@ using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Tools.Entities;
using Bit.Core.Tools.Enums;
using Bit.Core.Tools.Models.Business;
using Bit.Core.Tools.Repositories;
using Bit.Core.Tools.Services;
using Bit.Core.Utilities;
@ -63,10 +68,13 @@ public class AccountsController : Controller
private readonly ISetInitialMasterPasswordCommand _setInitialMasterPasswordCommand;
private readonly IRotateUserKeyCommand _rotateUserKeyCommand;
private readonly IFeatureService _featureService;
private readonly ICancelSubscriptionCommand _cancelSubscriptionCommand;
private readonly IGetSubscriptionQuery _getSubscriptionQuery;
private readonly IReferenceEventService _referenceEventService;
private readonly ICurrentContext _currentContext;
private bool UseFlexibleCollections =>
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
_featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections);
private readonly IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> _cipherValidator;
private readonly IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> _folderValidator;
@ -95,6 +103,9 @@ public class AccountsController : Controller
ISetInitialMasterPasswordCommand setInitialMasterPasswordCommand,
IRotateUserKeyCommand rotateUserKeyCommand,
IFeatureService featureService,
ICancelSubscriptionCommand cancelSubscriptionCommand,
IGetSubscriptionQuery getSubscriptionQuery,
IReferenceEventService referenceEventService,
ICurrentContext currentContext,
IRotationValidator<IEnumerable<CipherWithIdRequestModel>, IEnumerable<Cipher>> cipherValidator,
IRotationValidator<IEnumerable<FolderWithIdRequestModel>, IEnumerable<Folder>> folderValidator,
@ -121,6 +132,9 @@ public class AccountsController : Controller
_setInitialMasterPasswordCommand = setInitialMasterPasswordCommand;
_rotateUserKeyCommand = rotateUserKeyCommand;
_featureService = featureService;
_cancelSubscriptionCommand = cancelSubscriptionCommand;
_getSubscriptionQuery = getSubscriptionQuery;
_referenceEventService = referenceEventService;
_currentContext = currentContext;
_cipherValidator = cipherValidator;
_folderValidator = folderValidator;
@ -425,7 +439,7 @@ public class AccountsController : Controller
}
IdentityResult result;
if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements, _currentContext))
if (_featureService.IsEnabled(FeatureFlagKeys.KeyRotationImprovements))
{
var dataModel = new RotateUserKeyData
{
@ -809,16 +823,44 @@ public class AccountsController : Controller
[HttpPost("cancel-premium")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task PostCancel()
public async Task PostCancel([FromBody] SubscriptionCancellationRequestModel request)
{
var user = await _userService.GetUserByPrincipalAsync(User);
if (user == null)
{
throw new UnauthorizedAccessException();
}
var presentUserWithOffboardingSurvey =
_featureService.IsEnabled(FeatureFlagKeys.AC1607_PresentUsersWithOffboardingSurvey);
if (presentUserWithOffboardingSurvey)
{
var subscription = await _getSubscriptionQuery.GetSubscription(user);
await _cancelSubscriptionCommand.CancelSubscription(subscription,
new OffboardingSurveyResponse
{
UserId = user.Id,
Reason = request.Reason,
Feedback = request.Feedback
},
user.IsExpired());
await _referenceEventService.RaiseEventAsync(new ReferenceEvent(
ReferenceEventType.CancelSubscription,
user,
_currentContext)
{
EndOfPeriod = user.IsExpired()
});
}
else
{
await _userService.CancelPremiumAsync(user);
}
}
[HttpPost("reinstate-premium")]
[SelfHosted(NotSelfHostedOnly = true)]

View File

@ -13,7 +13,6 @@ using Bit.Core.Auth.UserFeatures.WebAuthnLogin;
using Bit.Core.Exceptions;
using Bit.Core.Services;
using Bit.Core.Tokens;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@ -21,7 +20,6 @@ namespace Bit.Api.Auth.Controllers;
[Route("webauthn")]
[Authorize("Web")]
[RequireFeature(FeatureFlagKeys.PasswordlessLogin)]
public class WebAuthnController : Controller
{
private readonly IUserService _userService;

View File

@ -66,6 +66,7 @@ public class SsoConfigurationDataRequest : IValidatableObject
public string ExpectedReturnAcrValue { get; set; }
// SAML2 SP
public bool? SpUniqueEntityId { get; set; }
public Saml2NameIdFormat SpNameIdFormat { get; set; }
public string SpOutboundSigningAlgorithm { get; set; }
public Saml2SigningBehavior SpSigningBehavior { get; set; }
@ -190,6 +191,7 @@ public class SsoConfigurationDataRequest : IValidatableObject
IdpAllowUnsolicitedAuthnResponse = IdpAllowUnsolicitedAuthnResponse.GetValueOrDefault(),
IdpDisableOutboundLogoutRequests = IdpDisableOutboundLogoutRequests.GetValueOrDefault(),
IdpWantAuthnRequestsSigned = IdpWantAuthnRequestsSigned.GetValueOrDefault(),
SpUniqueEntityId = SpUniqueEntityId.GetValueOrDefault(),
SpNameIdFormat = SpNameIdFormat,
SpOutboundSigningAlgorithm = SpOutboundSigningAlgorithm ?? SamlSigningAlgorithms.Sha256,
SpSigningBehavior = SpSigningBehavior,

View File

@ -33,7 +33,8 @@ public class SsoUrls
{
CallbackPath = SsoConfigurationData.BuildCallbackPath(globalSettings.BaseServiceUri.Sso);
SignedOutCallbackPath = SsoConfigurationData.BuildSignedOutCallbackPath(globalSettings.BaseServiceUri.Sso);
SpEntityId = SsoConfigurationData.BuildSaml2ModulePath(globalSettings.BaseServiceUri.Sso);
SpEntityIdStatic = SsoConfigurationData.BuildSaml2ModulePath(globalSettings.BaseServiceUri.Sso);
SpEntityId = SsoConfigurationData.BuildSaml2ModulePath(globalSettings.BaseServiceUri.Sso, organizationId);
SpMetadataUrl = SsoConfigurationData.BuildSaml2MetadataUrl(globalSettings.BaseServiceUri.Sso, organizationId);
SpAcsUrl = SsoConfigurationData.BuildSaml2AcsUrl(globalSettings.BaseServiceUri.Sso, organizationId);
}
@ -41,6 +42,7 @@ public class SsoUrls
public string CallbackPath { get; set; }
public string SignedOutCallbackPath { get; set; }
public string SpEntityId { get; set; }
public string SpEntityIdStatic { get; set; }
public string SpMetadataUrl { get; set; }
public string SpAcsUrl { get; set; }
}

View File

@ -28,8 +28,8 @@ public class CollectionsController : Controller
private readonly IAuthorizationService _authorizationService;
private readonly ICurrentContext _currentContext;
private readonly IBulkAddCollectionAccessCommand _bulkAddCollectionAccessCommand;
private readonly IFeatureService _featureService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IApplicationCacheService _applicationCacheService;
public CollectionsController(
ICollectionRepository collectionRepository,
@ -39,8 +39,8 @@ public class CollectionsController : Controller
IAuthorizationService authorizationService,
ICurrentContext currentContext,
IBulkAddCollectionAccessCommand bulkAddCollectionAccessCommand,
IFeatureService featureService,
IOrganizationUserRepository organizationUserRepository)
IOrganizationUserRepository organizationUserRepository,
IApplicationCacheService applicationCacheService)
{
_collectionRepository = collectionRepository;
_organizationUserRepository = organizationUserRepository;
@ -50,16 +50,14 @@ public class CollectionsController : Controller
_authorizationService = authorizationService;
_currentContext = currentContext;
_bulkAddCollectionAccessCommand = bulkAddCollectionAccessCommand;
_featureService = featureService;
_organizationUserRepository = organizationUserRepository;
_applicationCacheService = applicationCacheService;
}
private bool FlexibleCollectionsIsEnabled => _featureService.IsEnabled(FeatureFlagKeys.FlexibleCollections, _currentContext);
[HttpGet("{id}")]
public async Task<CollectionResponseModel> Get(Guid orgId, Guid id)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await Get_vNext(id);
@ -79,7 +77,7 @@ public class CollectionsController : Controller
[HttpGet("{id}/details")]
public async Task<CollectionAccessDetailsResponseModel> GetDetails(Guid orgId, Guid id)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await GetDetails_vNext(id);
@ -104,7 +102,7 @@ public class CollectionsController : Controller
else
{
(var collection, var access) = await _collectionRepository.GetByIdWithAccessAsync(id,
_currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
_currentContext.UserId.Value, false);
if (collection == null || collection.OrganizationId != orgId)
{
throw new NotFoundException();
@ -117,7 +115,7 @@ public class CollectionsController : Controller
[HttpGet("details")]
public async Task<ListResponseModel<CollectionAccessDetailsResponseModel>> GetManyWithDetails(Guid orgId)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await GetManyWithDetails_vNext(orgId);
@ -132,7 +130,7 @@ public class CollectionsController : Controller
// We always need to know which collections the current user is assigned to
var assignedOrgCollections =
await _collectionRepository.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId,
FlexibleCollectionsIsEnabled);
false);
if (await _currentContext.ViewAllCollections(orgId) || await _currentContext.ManageUsers(orgId))
{
@ -159,7 +157,7 @@ public class CollectionsController : Controller
[HttpGet("")]
public async Task<ListResponseModel<CollectionResponseModel>> Get(Guid orgId)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await GetByOrgId_vNext(orgId);
@ -191,7 +189,7 @@ public class CollectionsController : Controller
public async Task<ListResponseModel<CollectionDetailsResponseModel>> GetUser()
{
var collections = await _collectionRepository.GetManyByUserIdAsync(
_userService.GetProperUserId(User).Value, FlexibleCollectionsIsEnabled);
_userService.GetProperUserId(User).Value, false);
var responses = collections.Select(c => new CollectionDetailsResponseModel(c));
return new ListResponseModel<CollectionDetailsResponseModel>(responses);
}
@ -199,7 +197,7 @@ public class CollectionsController : Controller
[HttpGet("{id}/users")]
public async Task<IEnumerable<SelectionReadOnlyResponseModel>> GetUsers(Guid orgId, Guid id)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await GetUsers_vNext(id);
@ -215,11 +213,15 @@ public class CollectionsController : Controller
[HttpPost("")]
public async Task<CollectionResponseModel> Post(Guid orgId, [FromBody] CollectionRequestModel model)
{
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await Post_vNext(orgId, model);
}
var collection = model.ToCollection(orgId);
var authorized = FlexibleCollectionsIsEnabled
? (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Create)).Succeeded
: await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id);
var authorized = await CanCreateCollection(orgId, collection.Id) || await CanEditCollectionAsync(orgId, collection.Id);
if (!authorized)
{
throw new NotFoundException();
@ -230,7 +232,6 @@ public class CollectionsController : Controller
// Pre-flexible collections logic assigned Managers to collections they create
var assignUserToCollection =
!FlexibleCollectionsIsEnabled &&
!await _currentContext.EditAnyCollection(orgId) &&
await _currentContext.EditAssignedCollections(orgId);
var isNewCollection = collection.Id == default;
@ -251,14 +252,26 @@ public class CollectionsController : Controller
}
await _collectionService.SaveAsync(collection, groups, users);
if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(orgId))
{
return new CollectionResponseModel(collection);
}
// If we have a user, fetch the collection to get the latest permission details
var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id,
_currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId));
return userCollectionDetails == null
? new CollectionResponseModel(collection)
: new CollectionDetailsResponseModel(userCollectionDetails);
}
[HttpPut("{id}")]
[HttpPost("{id}")]
public async Task<CollectionResponseModel> Put(Guid orgId, Guid id, [FromBody] CollectionRequestModel model)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
return await Put_vNext(id, model);
@ -274,13 +287,24 @@ public class CollectionsController : Controller
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
var users = model.Users?.Select(g => g.ToSelectionReadOnly());
await _collectionService.SaveAsync(model.ToCollection(collection), groups, users);
if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId))
{
return new CollectionResponseModel(collection);
}
// If we have a user, fetch the collection details to get the latest permission details for the user
var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId));
return updatedCollectionDetails == null
? new CollectionResponseModel(collection)
: new CollectionDetailsResponseModel(updatedCollectionDetails);
}
[HttpPut("{id}/users")]
public async Task PutUsers(Guid orgId, Guid id, [FromBody] IEnumerable<SelectionReadOnlyRequestModel> model)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
await PutUsers_vNext(id, model);
@ -299,14 +323,17 @@ public class CollectionsController : Controller
[HttpPost("bulk-access")]
[RequireFeature(FeatureFlagKeys.BulkCollectionAccess)]
// Also gated behind Flexible Collections flag because it only has new authorization logic.
// Could be removed if legacy authorization logic were implemented for many collections.
[RequireFeature(FeatureFlagKeys.FlexibleCollections)]
public async Task PostBulkCollectionAccess([FromBody] BulkCollectionAccessRequestModel model)
public async Task PostBulkCollectionAccess(Guid orgId, [FromBody] BulkCollectionAccessRequestModel model)
{
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds);
// Authorization logic assumes flexible collections is enabled
// Remove after all organizations have been migrated
if (!await FlexibleCollectionsIsEnabledAsync(orgId))
{
throw new NotFoundException("Feature disabled.");
}
if (collections.Count != model.CollectionIds.Count())
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.CollectionIds);
if (collections.Count(c => c.OrganizationId == orgId) != model.CollectionIds.Count())
{
throw new NotFoundException("One or more collections not found.");
}
@ -328,7 +355,7 @@ public class CollectionsController : Controller
[HttpPost("{id}/delete")]
public async Task Delete(Guid orgId, Guid id)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
await Delete_vNext(id);
@ -349,7 +376,7 @@ public class CollectionsController : Controller
[HttpPost("delete")]
public async Task DeleteMany(Guid orgId, [FromBody] CollectionBulkDeleteRequestModel model)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
var collections = await _collectionRepository.GetManyByManyIdsAsync(model.Ids);
@ -385,7 +412,7 @@ public class CollectionsController : Controller
[HttpPost("{id}/delete-user/{orgUserId}")]
public async Task DeleteUser(Guid orgId, Guid id, Guid orgUserId)
{
if (FlexibleCollectionsIsEnabled)
if (await FlexibleCollectionsIsEnabledAsync(orgId))
{
// New flexible collections logic
await DeleteUser_vNext(id, orgUserId);
@ -397,19 +424,9 @@ public class CollectionsController : Controller
await _collectionService.DeleteUserAsync(collection, orgUserId);
}
private void DeprecatedPermissionsGuard()
{
if (FlexibleCollectionsIsEnabled)
{
throw new FeatureUnavailableException("Flexible Collections is ON when it should be OFF.");
}
}
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<Collection> GetCollectionAsync(Guid id, Guid orgId)
{
DeprecatedPermissionsGuard();
Collection collection = default;
if (await _currentContext.ViewAllCollections(orgId))
{
@ -417,7 +434,7 @@ public class CollectionsController : Controller
}
else if (await _currentContext.ViewAssignedCollections(orgId))
{
collection = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
collection = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, false);
}
if (collection == null || collection.OrganizationId != orgId)
@ -431,8 +448,6 @@ public class CollectionsController : Controller
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> CanCreateCollection(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId != default)
{
return false;
@ -445,8 +460,6 @@ public class CollectionsController : Controller
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> CanEditCollectionAsync(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId == default)
{
return false;
@ -460,7 +473,7 @@ public class CollectionsController : Controller
if (await _currentContext.EditAssignedCollections(orgId))
{
var collectionDetails =
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, false);
return collectionDetails != null;
}
@ -470,8 +483,6 @@ public class CollectionsController : Controller
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> CanDeleteCollectionAsync(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId == default)
{
return false;
@ -485,7 +496,7 @@ public class CollectionsController : Controller
if (await _currentContext.DeleteAssignedCollections(orgId))
{
var collectionDetails =
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, false);
return collectionDetails != null;
}
@ -495,8 +506,6 @@ public class CollectionsController : Controller
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> DeleteAnyCollection(Guid orgId)
{
DeprecatedPermissionsGuard();
return await _currentContext.OrganizationAdmin(orgId) ||
(_currentContext.Organizations?.Any(o => o.Id == orgId
&& (o.Permissions?.DeleteAnyCollection ?? false)) ?? false);
@ -505,8 +514,6 @@ public class CollectionsController : Controller
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> CanViewCollectionAsync(Guid orgId, Guid collectionId)
{
DeprecatedPermissionsGuard();
if (collectionId == default)
{
return false;
@ -520,7 +527,7 @@ public class CollectionsController : Controller
if (await _currentContext.ViewAssignedCollections(orgId))
{
var collectionDetails =
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
await _collectionRepository.GetByIdAsync(collectionId, _currentContext.UserId.Value, false);
return collectionDetails != null;
}
@ -530,8 +537,6 @@ public class CollectionsController : Controller
[Obsolete("Pre-Flexible Collections logic. Will be replaced by CollectionsAuthorizationHandler.")]
private async Task<bool> ViewAtLeastOneCollectionAsync(Guid orgId)
{
DeprecatedPermissionsGuard();
return await _currentContext.ViewAllCollections(orgId) || await _currentContext.ViewAssignedCollections(orgId);
}
@ -564,7 +569,7 @@ public class CollectionsController : Controller
{
// We always need to know which collections the current user is assigned to
var assignedOrgCollections = await _collectionRepository
.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, FlexibleCollectionsIsEnabled);
.GetManyByUserIdWithAccessAsync(_currentContext.UserId.Value, orgId, true);
var readAllAuthorized =
(await _authorizationService.AuthorizeAsync(User, CollectionOperations.ReadAllWithAccess(orgId))).Succeeded;
@ -604,7 +609,7 @@ public class CollectionsController : Controller
}
else
{
var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, FlexibleCollectionsIsEnabled);
var assignedCollections = await _collectionRepository.GetManyByUserIdAsync(_currentContext.UserId.Value, false);
orgCollections = assignedCollections.Where(c => c.OrganizationId == orgId && c.Manage).ToList();
}
@ -626,6 +631,35 @@ public class CollectionsController : Controller
return responses;
}
private async Task<CollectionResponseModel> Post_vNext(Guid orgId, [FromBody] CollectionRequestModel model)
{
var collection = model.ToCollection(orgId);
var authorized = (await _authorizationService.AuthorizeAsync(User, collection, BulkCollectionOperations.Create)).Succeeded;
if (!authorized)
{
throw new NotFoundException();
}
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
var users = model.Users?.Select(g => g.ToSelectionReadOnly()).ToList() ?? new List<CollectionAccessSelection>();
await _collectionService.SaveAsync(collection, groups, users);
if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(orgId))
{
return new CollectionResponseModel(collection);
}
// If we have a user, fetch the collection to get the latest permission details
var userCollectionDetails = await _collectionRepository.GetByIdAsync(collection.Id,
_currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId));
return userCollectionDetails == null
? new CollectionResponseModel(collection)
: new CollectionDetailsResponseModel(userCollectionDetails);
}
private async Task<CollectionResponseModel> Put_vNext(Guid id, CollectionRequestModel model)
{
var collection = await _collectionRepository.GetByIdAsync(id);
@ -638,9 +672,20 @@ public class CollectionsController : Controller
var groups = model.Groups?.Select(g => g.ToSelectionReadOnly());
var users = model.Users?.Select(g => g.ToSelectionReadOnly());
await _collectionService.SaveAsync(model.ToCollection(collection), groups, users);
if (!_currentContext.UserId.HasValue || await _currentContext.ProviderUserForOrgAsync(collection.OrganizationId))
{
return new CollectionResponseModel(collection);
}
// If we have a user, fetch the collection details to get the latest permission details for the user
var updatedCollectionDetails = await _collectionRepository.GetByIdAsync(id, _currentContext.UserId.Value, await FlexibleCollectionsIsEnabledAsync(collection.OrganizationId));
return updatedCollectionDetails == null
? new CollectionResponseModel(collection)
: new CollectionDetailsResponseModel(updatedCollectionDetails);
}
private async Task PutUsers_vNext(Guid id, IEnumerable<SelectionReadOnlyRequestModel> model)
{
var collection = await _collectionRepository.GetByIdAsync(id);
@ -676,4 +721,10 @@ public class CollectionsController : Controller
await _collectionService.DeleteUserAsync(collection, orgUserId);
}
private async Task<bool> FlexibleCollectionsIsEnabledAsync(Guid organizationId)
{
var organizationAbility = await _applicationCacheService.GetOrganizationAbilityAsync(organizationId);
return organizationAbility?.FlexibleCollections ?? false;
}
}

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