1
0
mirror of https://github.com/bitwarden/server.git synced 2024-11-25 12:45:18 +01:00

Merge branch 'master' into master

This commit is contained in:
Haneef 2023-01-28 00:06:39 +00:00 committed by GitHub
commit e2e4b1002b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2394 changed files with 310515 additions and 80805 deletions

22
.config/dotnet-tools.json Normal file
View File

@ -0,0 +1,22 @@
{
"version": 1,
"isRoot": true,
"tools": {
"swashbuckle.aspnetcore.cli": {
"version": "6.5.0",
"commands": ["swagger"]
},
"coverlet.console": {
"version": "3.1.2",
"commands": ["coverlet"]
},
"dotnet-reportgenerator-globaltool": {
"version": "5.1.6",
"commands": ["reportgenerator"]
},
"dotnet-ef": {
"version": "6.0.12",
"commands": ["dotnet-ef"]
}
}
}

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/bin
**/obj
**/node_modules

View File

@ -5,6 +5,7 @@ root = true
# Don't use tabs for indentation. # Don't use tabs for indentation.
[*] [*]
end_of_line = lf
indent_style = space indent_style = space
# (Please don't specify an indent_size here; that has too many unintended consequences.) # (Please don't specify an indent_size here; that has too many unintended consequences.)
@ -72,6 +73,15 @@ dotnet_naming_style.end_in_async.required_suffix = Async
dotnet_naming_style.end_in_async.capitalization = pascal_case dotnet_naming_style.end_in_async.capitalization = pascal_case
dotnet_naming_style.end_in_async.word_separator = dotnet_naming_style.end_in_async.word_separator =
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
dotnet_diagnostic.CS0618.severity = suggestion
# Obsolete warnings, this should be removed or changed to warning once we address some of the obsolete items.
dotnet_diagnostic.CS0612.severity = suggestion
# Remove unnecessary using directives https://docs.microsoft.com/en-us/dotnet/fundamentals/code-analysis/style-rules/ide0005
dotnet_diagnostic.IDE0005.severity = warning
# CSharp code style settings: # CSharp code style settings:
[*.cs] [*.cs]
# Prefer "var" everywhere # Prefer "var" everywhere
@ -104,6 +114,13 @@ csharp_new_line_before_finally = true
csharp_new_line_before_members_in_object_initializers = true csharp_new_line_before_members_in_object_initializers = true
csharp_new_line_before_members_in_anonymous_types = true csharp_new_line_before_members_in_anonymous_types = true
# Namespace settings
csharp_style_namespace_declarations = file_scoped:warning
# Switch expression
dotnet_diagnostic.CS8509.severity = error # missing switch case for named enum value
dotnet_diagnostic.CS8524.severity = none # missing switch case for unnamed enum value
# All files # All files
[*] [*]
guidelines = 120 guidelines = 120

11
.git-blame-ignore-revs Normal file
View File

@ -0,0 +1,11 @@
# Apply .NET format https://github.com/bitwarden/server/pull/1764
23b0a1f9df25058ab29785ecad9a233113c10889
# Turn on file scoped namespaces (gets reverted) https://github.com/bitwarden/server/pull/2225
34fb4cca2aa78deb84d4cbc359992a7c6bba7ea5
# Revert filescoped https://github.com/bitwarden/server/pull/2227
bae03feffecbef488cb52f5f5bc133dfdbbaa316
# Run formatting for file scoped namespaces https://github.com/bitwarden/server/pull/2230
7f5f010e1eea400300c47f776604ecf46c4b4f2d

8
.git-hooks/pre-commit Executable file
View File

@ -0,0 +1,8 @@
#!/bin/bash
FILES=$(git diff --cached --name-only --diff-filter=ACM "*.cs")
if [ -n "$FILES" ]
then
dotnet format ./bitwarden-server.sln --no-restore --include $FILES
echo "$FILES" | xargs git add
fi

1
.gitattributes vendored
View File

@ -1,3 +1,4 @@
*.sh eol=lf *.sh eol=lf
*.cs eol=lf
.dockerignore eol=lf .dockerignore eol=lf
dockerfile eol=lf dockerfile eol=lf

5
.github/CODEOWNERS vendored Normal file
View File

@ -0,0 +1,5 @@
# Please sort lines alphabetically, this will ensure we don't accidentally add duplicates.
#
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
**/SecretsManager @bitwarden/pod-sm-dev

View File

@ -1,4 +1,4 @@
name: Bug Report name: Server Bug Report
description: File a bug report description: File a bug report
labels: [bug] labels: [bug]
body: body:
@ -71,3 +71,11 @@ body:
- Operating system: [e.g. Windows 10, Mac OS Catalina] - Operating system: [e.g. Windows 10, Mac OS Catalina]
- Environment: [e.g. Docker, EKS, ECS, K8S] - Environment: [e.g. Docker, EKS, ECS, K8S]
- Hardware: [e.g. Intel 6-core, 8GB RAM] - Hardware: [e.g. Intel 6-core, 8GB RAM]
- type: checkboxes
id: issue-tracking-info
attributes:
label: Issue Tracking Info
description: |
Issue tracking information
options:
- label: I understand that work is tracked outside of Github. A PR will be linked to this issue should one be opened to address it, but Bitwarden doesn't use fields like "assigned", "milestone", or "project" to track progress.

90
.github/ISSUE_TEMPLATE/bw-unified.yml vendored Normal file
View File

@ -0,0 +1,90 @@
name: Bitwarden Unified Bug Report
name: Bitwarden Unified Deployment Bug Report
description: File a bug report
labels: [bug, bw-unified-deploy]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
Please do not submit feature requests. The [Community Forums](https://community.bitwarden.com) has a section for submitting, voting for, and discussing product feature requests.
- type: textarea
id: reproduce
attributes:
label: Steps To Reproduce
description: How can we reproduce the behavior.
value: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. Click on '...'
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Result
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Result
description: A clear and concise description of what is happening.
validations:
required: true
- type: textarea
id: screenshots
attributes:
label: Screenshots or Videos
description: If applicable, add screenshots and/or a short video to help explain your problem.
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other context about the problem here.
- type: input
id: version
attributes:
label: Githash Version
description: Please go to https://{your-bitwarden-domain}/api/config and copy the gitHash version
validations:
required: true
- type: textarea
id: environment-details
attributes:
label: Environment Details
description: If Self-Hosted please provide some additional environment details.
placeholder: |
- Operating system: [e.g. Windows 10, Mac OS Catalina]
- Environment: [e.g. Docker, EKS, ECS, K8S]
- Hardware: [e.g. Intel 6-core, 8GB RAM]
- type: textarea
id: database-image
attributes:
label: Database Image
description: Please include the image and version of your database
placeholder: |
# MariaDB Example
mariadb:10
# Postgres Example
postgres:14
- type: textarea
id: epic-label
attributes:
label: Issue-Link
description: Link to our pinned issue, tracking all Bitwarden Unified
value: |
https://github.com/bitwarden/server/issues/2480
validations:
required: true
- type: checkboxes
id: issue-tracking-info
attributes:
label: Issue Tracking Info
description: |
Issue tracking information
options:
- label: I understand that work is tracked outside of Github. A PR will be linked to this issue should one be opened to address it, but Bitwarden doesn't use fields like "assigned", "milestone", or "project" to track progress.

View File

@ -1,9 +1,14 @@
## Type of change ## Type of change
<!-- (mark with an `X`) -->
```
- [ ] Bug fix - [ ] Bug fix
- [ ] New feature development - [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc) - [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps) - [ ] Build/deploy pipeline (DevOps)
- [ ] Other - [ ] Other
```
## Objective ## Objective
<!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding--> <!--Describe what the purpose of this PR is. For example: what bug you're fixing or what new feature you're adding-->
@ -16,13 +21,10 @@
* **file.ext:** Description of what was changed and why * **file.ext:** Description of what was changed and why
## Testing requirements
<!--What functionality requires testing by QA? This includes testing new behavior and regression testing-->
## Before you submit ## Before you submit
- [ ] If making database changes - I have also updated Entity Framework queries and/or migrations
- [ ] I have added **unit tests** where it makes sense to do so (encouraged but not required) - Please check for formatting errors (`dotnet format --verify-no-changes`) (required)
- [ ] This change requires a **documentation update** (notify the documentation team) - If making database changes - make sure you also update Entity Framework queries and/or migrations
- [ ] This change has particular **deployment requirements** (notify the DevOps team) - Please add **unit tests** where it makes sense to do so (encouraged but not required)
- If this change requires a **documentation update** - notify the documentation team
- If this change has particular **deployment requirements** - notify the DevOps team

25
.github/renovate.json vendored Normal file
View File

@ -0,0 +1,25 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:base",
"schedule:monthly",
":maintainLockFilesMonthly",
":preserveSemverRanges",
":rebaseStalePrs",
":disableMajorUpdates"
],
"enabledManagers": [
"nuget"
],
"packageRules": [
{
"matchManagers": ["nuget"],
"groupName": "Nuget updates",
"groupSlug": "nuget",
"matchUpdateTypes": [
"minor",
"patch"
]
}
]
}

View File

@ -0,0 +1,64 @@
---
name: Automatic responses
on:
issues:
types:
- labeled
jobs:
close-issue:
name: 'Close issue with automatic response'
runs-on: ubuntu-20.04
permissions:
issues: write
steps:
# Feature request
- if: github.event.label.name == 'feature-request'
name: Feature request
uses: peter-evans/close-issue@849549ba7c3a595a064c4b2c56f206ee78f93515 # v2.0.0
with:
comment: |
We use GitHub issues as a place to track bugs and other development related issues. The [Bitwarden Community Forums](https://community.bitwarden.com/) has a [Feature Requests](https://community.bitwarden.com/c/feature-requests) section for submitting, voting for, and discussing requests like this one.
Please [sign up on our forums](https://community.bitwarden.com/signup) and search to see if this request already exists. If so, you can vote for it and contribute to any discussions about it. If not, you can re-create the request there so that it can be properly tracked.
This issue will now be closed. Thanks!
# Intended behavior
- if: github.event.label.name == 'intended-behavior'
name: Intended behaviour
uses: peter-evans/close-issue@849549ba7c3a595a064c4b2c56f206ee78f93515 # v2.0.0
with:
comment: |
Your issue appears to be describing the intended behavior of the software. If you want this to be changed, it would be a feature request.
We use GitHub issues as a place to track bugs and other development related issues. The [Bitwarden Community Forums](https://community.bitwarden.com/) has a [Feature Requests](https://community.bitwarden.com/c/feature-requests) section for submitting, voting for, and discussing requests like this one.
Please [sign up on our forums](https://community.bitwarden.com/signup) and search to see if this request already exists. If so, you can vote for it and contribute to any discussions about it. If not, you can re-create the request there so that it can be properly tracked.
This issue will now be closed. Thanks!
# Customer support request
- if: github.event.label.name == 'customer-support'
name: Customer Support request
uses: peter-evans/close-issue@849549ba7c3a595a064c4b2c56f206ee78f93515 # v2.0.0
with:
comment: |
We use GitHub issues as a place to track bugs and other development related issues. Your issue appears to be a support request, or would otherwise be better handled by our dedicated Customer Success team.
Please contact us using our [Contact page](https://bitwarden.com/contact). You can include a link to this issue in the message content.
Alternatively, you can also search for an answer in our [help documentation](https://bitwarden.com/help/) or get help from other Bitwarden users on our [community forums](https://community.bitwarden.com/c/support/). The issue here will be closed.
# Resolved
- if: github.event.label.name == 'resolved'
name: Resolved
uses: peter-evans/close-issue@849549ba7c3a595a064c4b2c56f206ee78f93515 # v2.0.0
with:
comment: |
Weve closed this issue, as it appears the original problem has been resolved. If this happens again or continues to be an problem, please respond to this issue with any additional detail to assist with reproduction and root cause analysis.
# Stale
- if: github.event.label.name == 'stale'
name: Stale
uses: peter-evans/close-issue@849549ba7c3a595a064c4b2c56f206ee78f93515 # v2.0.0
with:
comment: |
As we havent heard from you about this problem in some time, this issue will now be closed.
If this happens again or continues to be an problem, please respond to this issue with any additional detail to assist with reproduction and root cause analysis.

166
.github/workflows/build-self-host.yml vendored Normal file
View File

@ -0,0 +1,166 @@
---
name: Build Self-Host
on:
push:
branches-ignore:
- "l10n_master"
- "gh-pages"
paths-ignore:
- ".github/workflows/**"
workflow_dispatch:
jobs:
build-docker:
name: Build Docker image
runs-on: ubuntu-22.04
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Check Branch to Publish
env:
PUBLISH_BRANCHES: "master,rc,hotfix-rc"
id: publish-branch-check
run: |
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES
if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then
echo "is_publish_branch=true" >> $GITHUB_ENV
else
echo "is_publish_branch=false" >> $GITHUB_ENV
fi
########## Set up Docker ##########
- name: Set up QEMU emulators
uses: docker/setup-qemu-action@e81a89b1732b9c48d79cd809d8d81d79c4647a18
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8c0edbc76e98fa90f69d9a2c020dcb50019dc325
########## Login to Docker registries ##########
- name: Login to Azure - QA Subscription
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Login to Azure ACR
run: az acr login -n bitwardenqa
- name: Login to Azure - Prod Subscription
if: ${{ env.is_publish_branch == 'true' }}
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
if: ${{ env.is_publish_branch == 'true' }}
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
with:
keyvault: "bitwarden-prod-kv"
secrets: "docker-password,
docker-username,
dct-delegate-2-repo-passphrase,
dct-delegate-2-key"
- name: Log into Docker
if: ${{ env.is_publish_branch == 'true' }}
env:
DOCKER_USERNAME: ${{ steps.retrieve-secrets.outputs.docker-username }}
DOCKER_PASSWORD: ${{ steps.retrieve-secrets.outputs.docker-password }}
run: echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- name: Setup Docker Trust
if: ${{ env.is_publish_branch == 'true' }}
env:
DCT_DELEGATION_KEY_ID: "c9bde8ec820701516491e5e03d3a6354e7bd66d05fa3df2b0062f68b116dc59c"
DCT_DELEGATE_KEY: ${{ steps.retrieve-secrets.outputs.dct-delegate-2-key }}
DCT_REPO_PASSPHRASE: ${{ steps.retrieve-secrets.outputs.dct-delegate-2-repo-passphrase }}
run: |
mkdir -p ~/.docker/trust/private
echo "$DCT_DELEGATE_KEY" > ~/.docker/trust/private/$DCT_DELEGATION_KEY_ID.key
echo "DOCKER_CONTENT_TRUST=1" >> $GITHUB_ENV
echo "DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE=$DCT_REPO_PASSPHRASE" >> $GITHUB_ENV
########## Generate image tag and build Docker image ##########
- name: Generate Docker image tag
id: tag
run: |
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name
if [[ "$IMAGE_TAG" == "master" ]]; then
IMAGE_TAG=dev
elif [[ "$IMAGE_TAG" == "rc" ]] || [[ "$IMAGE_TAG" == "hotfix-rc" ]]; then
IMAGE_TAG=beta
fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
- name: Generate tag list
id: tag-list
env:
IMAGE_TAG: ${{ steps.tag.outputs.image_tag }}
run: |
if [ "$IMAGE_TAG" = "dev" ] || [ "$IMAGE_TAG" = "beta" ]; then
echo "tags=bitwardenqa.azurecr.io/self-host:${IMAGE_TAG},bitwarden/self-host:${IMAGE_TAG}" >> $GITHUB_OUTPUT
else
echo "tags=bitwardenqa.azurecr.io/self-host:${IMAGE_TAG}" >> $GITHUB_OUTPUT
fi
- name: Build Docker image
uses: docker/build-push-action@c56af957549030174b10d6867f20e78cfd7debc5
with:
context: .
file: docker-unified/Dockerfile
platforms: |
linux/amd64,
linux/arm/v7,
linux/arm64/v8
push: true
tags: ${{ steps.tag-list.outputs.tags }}
- name: Log out of Docker and disable Docker Notary
if: ${{ env.is_publish_branch == 'true' }}
run: |
docker logout
echo "DOCKER_CONTENT_TRUST=0" >> $GITHUB_ENV
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-22.04
needs: build-docker
steps:
- name: Check if any job failed
if: |
github.ref == 'refs/heads/master'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc'
env:
BUILD_DOCKER_STATUS: ${{ needs.build-docker.result }}
run: |
if [ "$BUILD_DOCKER_STATUS" = "failure" ]; then
exit 1
fi
- name: Login to Azure - Prod Subscription
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
if: failure()
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
if: failure()
with:
keyvault: "bitwarden-prod-kv"
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@da3191ebe2e67f49b46880b4633f5591a96d1d33
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@ -4,18 +4,19 @@ name: Build
on: on:
push: push:
branches-ignore: branches-ignore:
- 'l10n_master' - "l10n_master"
- 'gh-pages' - "gh-pages"
paths-ignore:
- ".github/workflows/**"
workflow_dispatch: workflow_dispatch:
inputs: {}
jobs: jobs:
cloc: cloc:
name: CLOC name: CLOC
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b
- name: Install cloc - name: Install cloc
run: | run: |
@ -25,43 +26,46 @@ jobs:
- name: Print lines of code - name: Print lines of code
run: cloc --include-lang C#,SQL,Razor,"Bourne Shell",PowerShell,HTML,CSS,Sass,JavaScript,TypeScript --vcs git 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
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Verify Format
run: dotnet format --verify-no-changes
testing: testing:
name: Testing name: Testing
runs-on: windows-2019 runs-on: windows-2022
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps: steps:
- name: Set up NuGet - name: Set up dotnet
uses: nuget/setup-nuget@04b0c2b8d1b97922f67eca497d7cf0bf17b8ffe1 uses: actions/setup-dotnet@9211491ffb35dd6a6657ca4f45d43dfe6e97c829
with: with:
nuget-version: '5' dotnet-version: "6.0.x"
- name: Set up MSBuild - name: Set up MSBuild
uses: microsoft/setup-msbuild@c26a08ba26249b81327e26f6ef381897b6a8754d uses: microsoft/setup-msbuild@ab534842b4bdf384b8aaf93765dc6f721d9f5fab
- name: Set up Node
uses: actions/setup-node@46071b5c7a2e0c34e49c3cb8a0e792e86e18d5ea
with:
node-version: '14'
- name: Update NPM
run: |
npm install -g npm@7
- name: Print environment - name: Print environment
run: | run: |
nuget help | grep Version
msbuild -version
dotnet --info dotnet --info
node --version msbuild -version
npm --version nuget help | grep Version
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Restore - name: Restore
run: msbuild /t:restore run: dotnet restore --locked-mode
shell: pwsh
- name: Build OSS solution
run: msbuild bitwarden-server.sln /p:Configuration=Debug /p:DefineConstants="OSS" /verbosity:minimal
shell: pwsh shell: pwsh
- name: Build solution - name: Build solution
@ -69,58 +73,69 @@ jobs:
shell: pwsh shell: pwsh
- name: Test OSS solution - name: Test OSS solution
run: dotnet test ./test --configuration Debug --no-build run: dotnet test ./test --configuration Debug --no-build --logger "trx;LogFileName=oss-test-results.trx" || true
shell: pwsh shell: pwsh
- name: Test Bitwarden solution - name: Test Bitwarden solution
run: dotnet test ./bitwarden_license/test/CmmCore.Test --configuration Debug --no-build run: dotnet test ./bitwarden_license/test --configuration Debug --no-build --logger "trx;LogFileName=bw-test-results.trx" || true
shell: pwsh shell: pwsh
- name: Report test results
uses: dorny/test-reporter@c9b3d0e2bd2a4e96aaf424dbaa31c46b42318226
if: always()
with:
name: Test Results
path: "**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
build-artifacts: build-artifacts:
name: Build artifacts name: Build artifacts
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: testing needs:
- testing
- lint
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- service_name: Admin - project_name: Admin
base_path: ./src base_path: ./src
gulp: true node: true
- service_name: Api - project_name: Api
base_path: ./src base_path: ./src
- service_name: Billing - project_name: Billing
base_path: ./src base_path: ./src
- service_name: Events - project_name: Events
base_path: ./src base_path: ./src
- service_name: EventsProcessor - project_name: EventsProcessor
base_path: ./src base_path: ./src
- service_name: Icons - project_name: Icons
base_path: ./src base_path: ./src
- service_name: Identity - project_name: Identity
base_path: ./src base_path: ./src
- service_name: Notifications - project_name: Notifications
base_path: ./src base_path: ./src
- service_name: Server - project_name: Server
base_path: ./util base_path: ./util
- service_name: Setup - project_name: Setup
base_path: ./util base_path: ./util
- service_name: Sso - project_name: Sso
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
gulp: true node: true
- project_name: Scim
base_path: ./bitwarden_license/src
dotnet: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Set up Node - name: Set up Node
uses: actions/setup-node@46071b5c7a2e0c34e49c3cb8a0e792e86e18d5ea uses: actions/setup-node@9ced9a43a244f3ac94f13bfd896db8c8f30da67a
with: with:
node-version: '14' cache: "npm"
cache-dependency-path: "**/package-lock.json"
- name: Update NPM node-version: "16"
run: |
npm install -g npm@7
- name: Print environment - name: Print environment
run: | run: |
@ -128,288 +143,273 @@ jobs:
dotnet --info dotnet --info
node --version node --version
npm --version npm --version
gulp --version
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
- name: Set up Gulp - name: Restore/Clean project
if: ${{ matrix.gulp }} working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
working-directory: ${{ matrix.base_path }}/${{ matrix.service_name }}
run: |
npm install -g gulp
- name: Restore/Clean service
working-directory: ${{ matrix.base_path }}/${{ matrix.service_name }}
run: | run: |
echo "Restore" echo "Restore"
dotnet restore dotnet restore
echo "Clean" echo "Clean"
dotnet clean -c "Release" -o obj/build-output/publish dotnet clean -c "Release" -o obj/build-output/publish
- name: Execute Gulp - name: Build node
if: ${{ matrix.gulp }} if: ${{ matrix.node }}
working-directory: ${{ matrix.base_path }}/${{ matrix.service_name }} working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
run: | run: |
npm install npm ci
gulp --gulpfile gulpfile.js build npm run build
- name: Publish service - name: Publish project
working-directory: ${{ matrix.base_path }}/${{ matrix.service_name }} working-directory: ${{ matrix.base_path }}/${{ matrix.project_name }}
run: | run: |
echo "Publish" echo "Publish"
dotnet publish -c "Release" -o obj/build-output/publish dotnet publish -c "Release" -o obj/build-output/publish
cd obj/build-output/publish cd obj/build-output/publish
zip -r ${{ matrix.service_name }}.zip . zip -r ${{ matrix.project_name }}.zip .
mv ${{ matrix.service_name }}.zip ../../../ mv ${{ matrix.project_name }}.zip ../../../
pwd pwd
ls -atlh ../../../ ls -atlh ../../../
- name: Upload service artifact - name: Upload project artifact
uses: actions/upload-artifact@ee69f02b3dfdecd58bb31b4d133da38ba6fe3700 uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with: with:
name: ${{ matrix.service_name }}.zip name: ${{ matrix.project_name }}.zip
path: ${{ matrix.base_path }}/${{ matrix.service_name }}/${{ matrix.service_name }}.zip path: ${{ matrix.base_path }}/${{ matrix.project_name }}/${{ matrix.project_name }}.zip
if-no-files-found: error if-no-files-found: error
build-docker: build-docker:
name: Build Docker images name: Build Docker images
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: build-artifacts needs: build-artifacts
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- service_name: Admin - project_name: Admin
base_path: ./src base_path: ./src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Api - project_name: Api
base_path: ./src base_path: ./src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Attachments - project_name: Attachments
base_path: ./util base_path: ./util
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
- service_name: Events - project_name: Events
base_path: ./src base_path: ./src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: EventsProcessor - project_name: EventsProcessor
base_path: ./src base_path: ./src
docker_repo: bitwardenqa.azurecr.io docker_repos: [bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Icons - project_name: Icons
base_path: ./src base_path: ./src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Identity - project_name: Identity
base_path: ./src base_path: ./src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: K8S-Proxy - project_name: MsSql
base_path: ./util base_path: ./util
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
- service_name: MsSql - project_name: Nginx
base_path: ./util base_path: ./util
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
- service_name: Nginx - project_name: Notifications
base_path: ./util
docker_repo: bitwarden
- service_name: Notifications
base_path: ./src base_path: ./src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Server - project_name: Server
base_path: ./util base_path: ./util
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Setup - project_name: Setup
base_path: ./util base_path: ./util
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true dotnet: true
- service_name: Sso - project_name: Sso
base_path: ./bitwarden_license/src base_path: ./bitwarden_license/src
docker_repo: bitwarden docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true
- project_name: Scim
base_path: ./bitwarden_license/src
docker_repos: [bitwarden, bitwardenqa.azurecr.io]
dotnet: true
- project_name: Billing
base_path: ./src
docker_repos: [bitwardenqa.azurecr.io]
dotnet: true dotnet: true
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Login to Azure - Prod Subscription ########## Build Docker Image ##########
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a - name: Setup project name
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: Azure/get-keyvault-secrets@80ccd3fafe5662407cc2e55f202ee34bfff8c403
with:
keyvault: "bitwarden-prod-kv"
secrets: "aws-ecr-access-key-id,
aws-ecr-secret-access-key,
docker-password,
docker-username,
dct-delegate-2-repo-passphrase,
dct-delegate-2-key"
- name: Login to Azure - QA Subscription
if: ${{ matrix.service_name }} == "EventsProcessor"
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Log into Docker
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix'
env:
DOCKER_USERNAME: ${{ steps.retrieve-secrets.outputs.docker-username }}
DOCKER_PASSWORD: ${{ steps.retrieve-secrets.outputs.docker-password }}
run: |
if [[ "${{ matrix.docker_repo }}" == "bitwardenqa.azurecr.io" ]]; then
az acr login -n bitwardenqa
else
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
fi
- name: Setup Docker Trust
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix'
env:
DCT_DELEGATION_KEY_ID: "c9bde8ec820701516491e5e03d3a6354e7bd66d05fa3df2b0062f68b116dc59c"
DCT_DELEGATE_KEY: ${{ steps.retrieve-secrets.outputs.dct-delegate-2-key }}
run: |
mkdir -p ~/.docker/trust/private
echo "$DCT_DELEGATE_KEY" > ~/.docker/trust/private/$DCT_DELEGATION_KEY_ID.key
- name: Setup service name
id: setup id: setup
run: | run: |
SERVICE_NAME=$(echo "${{ matrix.service_name }}" | awk '{print tolower($0)}') PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.service_name }}" echo "Matrix name: ${{ matrix.project_name }}"
echo "SERVICE_NAME: $SERVICE_NAME" echo "PROJECT_NAME: $PROJECT_NAME"
echo "::set-output name=service_name::$SERVICE_NAME" echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
- name: Get build artifact - name: Get build artifact
if: ${{ matrix.dotnet }} if: ${{ matrix.dotnet }}
uses: actions/download-artifact@3be87be14a055c47b01d3bd88f8fe02320a9bb60 # v2.0.10 uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with: with:
name: ${{ matrix.service_name }}.zip name: ${{ matrix.project_name }}.zip
- name: Setup build artifact - name: Setup build artifact
if: ${{ matrix.dotnet }} if: ${{ matrix.dotnet }}
run: | run: |
mkdir -p ${{ matrix.base_path}}/${{ matrix.service_name }}/obj/build-output/publish mkdir -p ${{ matrix.base_path}}/${{ matrix.project_name }}/obj/build-output/publish
unzip ${{ matrix.service_name }}.zip \ unzip ${{ matrix.project_name }}.zip \
-d ${{ matrix.base_path }}/${{ matrix.service_name }}/obj/build-output/publish -d ${{ matrix.base_path }}/${{ matrix.project_name }}/obj/build-output/publish
- name: Build Docker images - name: Build Docker image
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: docker build -t $PROJECT_NAME ${{ matrix.base_path }}/${{ matrix.project_name }}
########## ACR ##########
- name: Login to Azure - QA Subscription
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Login to Azure ACR
run: az acr login -n bitwardenqa
- name: Tag and Push image to Azure ACR QA registry
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwardenqa.azurecr.io
run: | run: |
if [ "${{ matrix.service_name }}" = "K8S-Proxy" ]; then IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name
docker build -f ${{ matrix.base_path }}/Nginx/Dockerfile-k8s \ if [[ "$IMAGE_TAG" == "master" ]]; then
-t ${{ steps.setup.outputs.service_name }} ${{ matrix.base_path }}/Nginx IMAGE_TAG=dev
else
docker build -t ${{ steps.setup.outputs.service_name }} \
${{ matrix.base_path }}/${{ matrix.service_name }}
fi fi
- name: Docker Trust setup docker tag $PROJECT_NAME \
$REGISTRY/$PROJECT_NAME:$IMAGE_TAG
docker push $REGISTRY/$PROJECT_NAME:$IMAGE_TAG
- name: Log out of Docker
run: docker logout
########## DockerHub ##########
- name: Login to Azure - Prod Subscription
if: | if: |
matrix.docker_repo == 'bitwarden' contains(matrix.docker_repos, 'bitwarden')
&& (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix') && (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc')
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
if: |
contains(matrix.docker_repos, 'bitwarden')
&& (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc')
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
with:
keyvault: "bitwarden-prod-kv"
secrets: "docker-password,
docker-username,
dct-delegate-2-repo-passphrase,
dct-delegate-2-key"
- name: Log into Docker
if: |
contains(matrix.docker_repos, 'bitwarden')
&& (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc')
env: env:
DOCKER_USERNAME: ${{ steps.retrieve-secrets.outputs.docker-username }}
DOCKER_PASSWORD: ${{ steps.retrieve-secrets.outputs.docker-password }}
run: echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- name: Setup Docker Trust
if: |
contains(matrix.docker_repos, 'bitwarden')
&& (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc')
env:
DCT_DELEGATION_KEY_ID: "c9bde8ec820701516491e5e03d3a6354e7bd66d05fa3df2b0062f68b116dc59c"
DCT_DELEGATE_KEY: ${{ steps.retrieve-secrets.outputs.dct-delegate-2-key }}
DCT_REPO_PASSPHRASE: ${{ steps.retrieve-secrets.outputs.dct-delegate-2-repo-passphrase }} DCT_REPO_PASSPHRASE: ${{ steps.retrieve-secrets.outputs.dct-delegate-2-repo-passphrase }}
run: | run: |
mkdir -p ~/.docker/trust/private
echo "$DCT_DELEGATE_KEY" > ~/.docker/trust/private/$DCT_DELEGATION_KEY_ID.key
echo "DOCKER_CONTENT_TRUST=1" >> $GITHUB_ENV echo "DOCKER_CONTENT_TRUST=1" >> $GITHUB_ENV
echo "DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE=$DCT_REPO_PASSPHRASE" >> $GITHUB_ENV echo "DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE=$DCT_REPO_PASSPHRASE" >> $GITHUB_ENV
- name: Tag and Push RC to Docker Hub - name: Tag and Push RC to Docker Hub
if: github.ref == 'refs/heads/rc' if: |
contains(matrix.docker_repos, 'bitwarden')
&& (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc')
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwarden
run: | run: |
docker tag ${{ steps.setup.outputs.service_name }} \ IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name
${{ matrix.docker_repo }}/${{ steps.setup.outputs.service_name }}:rc if [[ "$IMAGE_TAG" == "master" ]]; then
docker push ${{ matrix.docker_repo }}/${{ steps.setup.outputs.service_name }}:rc IMAGE_TAG=dev
fi
- name: Tag and Push Hotfix to Docker Hub docker tag $PROJECT_NAME \
if: github.ref == 'refs/heads/hotfix' $REGISTRY/$PROJECT_NAME:$IMAGE_TAG
run: | docker push $REGISTRY/$PROJECT_NAME:$IMAGE_TAG
docker tag ${{ steps.setup.outputs.service_name }} \
${{ matrix.docker_repo }}/${{ steps.setup.outputs.service_name }}:hotfix
docker push ${{ matrix.docker_repo }}/${{ steps.setup.outputs.service_name }}:hotfix
- name: Tag and Push Dev to Docker Hub
if: github.ref == 'refs/heads/master'
run: |
docker tag ${{ steps.setup.outputs.service_name }} \
${{ matrix.docker_repo }}/${{ steps.setup.outputs.service_name }}:dev
docker push ${{ matrix.docker_repo }}/${{ steps.setup.outputs.service_name }}:dev
- name: Log out of Docker and disable Docker Notary - name: Log out of Docker and disable Docker Notary
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix' if: |
contains(matrix.docker_repos, 'bitwarden')
&& (github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc')
run: | run: |
docker logout docker logout
echo "DOCKER_CONTENT_TRUST=0" >> $GITHUB_ENV echo "DOCKER_CONTENT_TRUST=0" >> $GITHUB_ENV
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@0d9a5be0dceea74e09396820e1e522ba4a110d2f # v1
with:
aws-access-key-id: ${{ steps.retrieve-secrets.outputs.aws-ecr-access-key-id }}
aws-secret-access-key: ${{ steps.retrieve-secrets.outputs.aws-ecr-secret-access-key }}
aws-region: us-east-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@aaf69d68aa3fb14c1d5a6be9ac61fe15b48453a2 # v1
- name: Tag and Push RC to AWS ECR nonprod registry
if: github.ref == 'refs/heads/rc'
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker tag ${{ steps.setup.outputs.service_name }} \
$ECR_REGISTRY/nonprod/${{ steps.setup.outputs.service_name }}:rc-${IMAGE_TAG:(-8)}
docker push $ECR_REGISTRY/nonprod/${{ steps.setup.outputs.service_name }}:rc-${IMAGE_TAG:(-8)}
- name: Tag and Push Hotfix to AWS ECR nonprod registry
if: github.ref == 'refs/heads/hotfix'
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker tag ${{ steps.setup.outputs.service_name }} \
$ECR_REGISTRY/nonprod/${{ steps.setup.outputs.service_name }}:hotfix-${IMAGE_TAG:(-8)}
docker push $ECR_REGISTRY/nonprod/${{ steps.setup.outputs.service_name }}:hotfix-${IMAGE_TAG:(-8)}
- name: Tag and Push Dev to AWS ECR nonprod registry
if: github.ref == 'refs/heads/master'
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG: ${{ github.sha }}
run: |
docker tag ${{ steps.setup.outputs.service_name }} \
$ECR_REGISTRY/nonprod/${{ steps.setup.outputs.service_name }}:dev-${IMAGE_TAG:(-8)}
docker push $ECR_REGISTRY/nonprod/${{ steps.setup.outputs.service_name }}:dev-${IMAGE_TAG:(-8)}
upload: upload:
name: Upload name: Upload
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: build-docker needs: build-docker
steps: steps:
- name: Set up dotnet
uses: actions/setup-dotnet@9211491ffb35dd6a6657ca4f45d43dfe6e97c829
with:
dotnet-version: "6.0.x"
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Restore - name: Restore
run: dotnet tool restore run: dotnet tool restore
- name: Make Docker stub - name: Make Docker stub
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix' if: github.ref == 'refs/heads/master' ||
github.ref == 'refs/heads/rc' ||
github.ref == 'refs/heads/hotfix-rc'
run: | run: |
if [[ "${{ github.ref }}" == "rc" ]]; then if [[ "${{ github.ref }}" == "rc" ]]; then
SETUP_IMAGE="bitwarden/setup:rc" SETUP_IMAGE="bitwarden/setup:rc"
elif [[ "${{ github.ref }}" == "hotfix" ]]; then elif [[ "${{ github.ref }}" == "hotfix-rc" ]]; then
SETUP_IMAGE="bitwarden/setup:hotfix" SETUP_IMAGE="bitwarden/setup:hotfix-rc"
else else
SETUP_IMAGE="bitwarden/setup:dev" SETUP_IMAGE="bitwarden/setup:dev"
fi fi
@ -423,12 +423,24 @@ jobs:
touch $STUB_OUTPUT/env/uid.env touch $STUB_OUTPUT/env/uid.env
cd docker-stub; zip -r ../docker-stub.zip *; cd .. cd docker-stub; zip -r ../docker-stub.zip *; cd ..
- name: Make Docker stub checksum
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
run: sha256sum docker-stub.zip > docker-stub-sha256.txt
- name: Upload Docker stub artifact - name: Upload Docker stub artifact
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix' if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@ee69f02b3dfdecd58bb31b4d133da38ba6fe3700 uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with: with:
name: docker-stub.zip name: docker-stub.zip
path: ./docker-stub.zip path: docker-stub.zip
if-no-files-found: error
- name: Upload Docker stub checksum artifact
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/hotfix-rc'
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: docker-stub-sha256.txt
path: docker-stub-sha256.txt
if-no-files-found: error if-no-files-found: error
- name: Build Swagger - name: Build Swagger
@ -446,21 +458,24 @@ jobs:
cd ../.. cd ../..
env: env:
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
swaggerGen: 'True' swaggerGen: "True"
DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2
GLOBALSETTINGS__SQLSERVER__CONNECTIONSTRING: "placeholder"
- name: Upload Swagger artifact - name: Upload Swagger artifact
uses: actions/upload-artifact@ee69f02b3dfdecd58bb31b4d133da38ba6fe3700 uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with: with:
name: swagger.json name: swagger.json
path: ./swagger.json path: swagger.json
if-no-files-found: error if-no-files-found: error
check-failures: check-failures:
name: Check for failures name: Check for failures
if: always() if: always()
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- cloc - cloc
- lint
- testing - testing
- build-artifacts - build-artifacts
- build-docker - build-docker
@ -470,9 +485,10 @@ jobs:
if: | if: |
github.ref == 'refs/heads/master' github.ref == 'refs/heads/master'
|| github.ref == 'refs/heads/rc' || github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix' || github.ref == 'refs/heads/hotfix-rc'
env: env:
CLOC_STATUS: ${{ needs.cloc.result }} CLOC_STATUS: ${{ needs.cloc.result }}
LINT_STATUS: ${{ needs.lint.result }}
TESTING_STATUS: ${{ needs.testing.result }} TESTING_STATUS: ${{ needs.testing.result }}
BUILD_ARTIFACTS_STATUS: ${{ needs.build-artifacts.result }} BUILD_ARTIFACTS_STATUS: ${{ needs.build-artifacts.result }}
BUILD_DOCKER_STATUS: ${{ needs.build-docker.result }} BUILD_DOCKER_STATUS: ${{ needs.build-docker.result }}
@ -480,6 +496,8 @@ jobs:
run: | run: |
if [ "$CLOC_STATUS" = "failure" ]; then if [ "$CLOC_STATUS" = "failure" ]; then
exit 1 exit 1
elif [ "$LINT_STATUS" = "failure" ]; then
exit 1
elif [ "$TESTING_STATUS" = "failure" ]; then elif [ "$TESTING_STATUS" = "failure" ]; then
exit 1 exit 1
elif [ "$BUILD_ARTIFACTS_STATUS" = "failure" ]; then elif [ "$BUILD_ARTIFACTS_STATUS" = "failure" ]; then
@ -491,21 +509,21 @@ jobs:
fi fi
- name: Login to Azure - Prod Subscription - name: Login to Azure - Prod Subscription
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
if: failure() if: failure()
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets - name: Retrieve secrets
id: retrieve-secrets id: retrieve-secrets
uses: Azure/get-keyvault-secrets@80ccd3fafe5662407cc2e55f202ee34bfff8c403 uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
if: failure() if: failure()
with: with:
keyvault: "bitwarden-prod-kv" keyvault: "bitwarden-prod-kv"
secrets: "devops-alerts-slack-webhook-url" secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure - name: Notify Slack on failure
uses: act10ns/slack@e4e71685b9b239384b0f676a63c32367f59c2522 # v1.2.2 uses: act10ns/slack@da3191ebe2e67f49b46880b4633f5591a96d1d33
if: failure() if: failure()
env: env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }} SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}

66
.github/workflows/cleanup-after-pr.yml vendored Normal file
View File

@ -0,0 +1,66 @@
---
name: Clean After PR
on:
pull_request:
types: [closed]
jobs:
build-docker:
name: Remove feature branch docker images
runs-on: ubuntu-20.04
steps:
- name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
########## ACR ##########
- name: Login to Azure - QA Subscription
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Login to Azure ACR
run: az acr login -n bitwardenqa
########## Remove Docker images ##########
- name: Remove the docker image from ACR
env:
REGISTRY_NAME: bitwardenqa
SERVICES: |
services:
- Admin
- Api
- Attachments
- Events
- EventsProcessor
- Icons
- Identity
- K8S-Proxy
- MsSql
- Nginx
- Notifications
- Server
- Setup
- Sso
run: |
for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - )
do
SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}')
IMAGE_TAG=$(echo "${GITHUB_REF:11}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $REGISTRY_NAME.azurecr.io/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$(
az acr repository show-tags --name $REGISTRY_NAME --repository $SERVICE_NAME \
| jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")'
)
if [[ "$TAG_EXISTS" == "true" ]]; then
echo "[*] Tag exists. Removing tag"
az acr repository delete --name $REGISTRY_NAME --image $SERVICE_NAME:$IMAGE_TAG --yes
else
echo "[*] Tag does not exist. No action needed"
fi
done
- name: Log out of Docker
run: docker logout

View File

@ -0,0 +1,121 @@
---
name: Container Registry Purge
on:
schedule:
- cron: '0 0 * * SUN'
workflow_dispatch:
inputs: {}
jobs:
purge:
name: Purge old images
runs-on: ubuntu-20.04
strategy:
fail-fast: false
matrix:
include:
- name: bitwardenqa
- name: bitwardenprod
steps:
- name: Login to Azure
if: matrix.name == 'bitwardenprod'
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Login to Azure
if: matrix.name == 'bitwardenqa'
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Purge images
env:
REGISTRY: ${{ matrix.name }}
AGO_DUR_VER: "180d"
AGO_DUR: "30d"
run: |
REPO_LIST=$(az acr repository list -n $REGISTRY -o tsv)
for REPO in $REPO_LIST
do
PURGE_LATEST=""
PURGE_VERSION=""
PURGE_ELSE=""
TAG_LIST=$(az acr repository show-tags -n $REGISTRY --repository $REPO -o tsv)
for TAG in $TAG_LIST
do
if [ $TAG = "latest" ] || [ $TAG = "dev" ]; then
PURGE_LATEST+="--filter '$REPO:$TAG' "
elif [[ $TAG =~ [0-9]+\.[0-9]+\.[0-9]+ ]]; then
PURGE_VERSION+="--filter '$REPO:$TAG' "
else
PURGE_ELSE+="--filter '$REPO:$TAG' "
fi
done
if [ ! -z "$PURGE_LATEST" ]
then
PURGE_LATEST_CMD="acr purge $PURGE_LATEST --ago $AGO_DUR_VER --untagged --keep 1"
az acr run --cmd "$PURGE_LATEST_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_VERSION" ]
then
PURGE_VERSION_CMD="acr purge $PURGE_VERSION --ago $AGO_DUR_VER --untagged"
az acr run --cmd "$PURGE_VERSION_CMD" --registry $REGISTRY /dev/null &
fi
if [ ! -z "$PURGE_ELSE" ]
then
PURGE_ELSE_CMD="acr purge $PURGE_ELSE --ago $AGO_DUR --untagged"
az acr run --cmd "$PURGE_ELSE_CMD" --registry $REGISTRY /dev/null &
fi
wait
done
check-failures:
name: Check for failures
if: always()
runs-on: ubuntu-20.04
needs:
- purge
steps:
- name: Check if any job failed
if: |
github.ref == 'refs/heads/master'
|| github.ref == 'refs/heads/rc'
|| github.ref == 'refs/heads/hotfix-rc'
env:
PURGE_STATUS: ${{ needs.purge.result }}
run: |
if [ "$PURGE_STATUS" = "failure" ]; then
exit 1
fi
- name: Login to Azure - Prod Subscription
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
if: failure()
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: Azure/get-keyvault-secrets@b5c723b9ac7870c022b8c35befe620b7009b336f
if: failure()
with:
keyvault: "bitwarden-prod-kv"
secrets: "devops-alerts-slack-webhook-url"
- name: Notify Slack on failure
uses: act10ns/slack@da3191ebe2e67f49b46880b4633f5591a96d1d33
if: failure()
env:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

107
.github/workflows/database.yml vendored Normal file
View File

@ -0,0 +1,107 @@
---
name: Validate Database
on:
pull_request:
branches-ignore:
- 'l10n_master'
- 'gh-pages'
paths:
- 'src/Sql/**'
- 'util/Migrator/**'
push:
branches:
- 'master'
- 'rc'
paths:
- 'src/Sql/**'
- 'util/Migrator/**'
workflow_dispatch:
inputs: {}
jobs:
build:
name: Build DACPAC
runs-on: windows-2022
env:
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
steps:
- name: Set up dotnet
uses: actions/setup-dotnet@9211491ffb35dd6a6657ca4f45d43dfe6e97c829
with:
dotnet-version: '6.0.x'
- name: Set up MSBuild
uses: microsoft/setup-msbuild@ab534842b4bdf384b8aaf93765dc6f721d9f5fab
- name: Print environment
run: |
dotnet --info
msbuild -version
nuget help | grep Version
echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT"
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Build DACPAC
run: msbuild src/Sql/Sql.sqlproj /p:Configuration=Release /verbosity:minimal
shell: pwsh
- name: Upload DACPAC
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: sql.dacpac
path: src/Sql/bin/Release/Sql.dacpac
validate:
name: Validate
runs-on: ubuntu-20.04
needs: build
steps:
- name: Checkout repo
uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846
- name: Download dacpac
uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741
with:
name: 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: Upload Report
uses: actions/upload-artifact@6673cd052c4cd6fcf4b4e6e60ea986c889389535
with:
name: report.xml
path: report.xml
- 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

16
.github/workflows/enforce-labels.yml vendored Normal file
View File

@ -0,0 +1,16 @@
---
name: Enforce PR labels
on:
pull_request:
types: [labeled, unlabeled, opened, edited, synchronize]
jobs:
enforce-label:
name: EnforceLabel
runs-on: ubuntu-20.04
steps:
- name: Enforce Label
uses: yogevbd/enforce-label-action@a3c219da6b8fa73f6ba62b68ff09c469b3a1c024
with:
BANNED_LABELS: "hold,DB-migrations-changed,needs-qa"

View File

@ -1,27 +0,0 @@
---
name: Workflow Linter
on:
push:
branches: add-workflow-linter
# branches-ignore:
# - 'l10n_master'
# - 'gh-pages'
# workflow_dispatch:
# inputs: {}
jobs:
cloc:
name: CLOC
runs-on: ubuntu-20.04
steps:
- name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f # v2.3.4
- 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

55
.github/workflows/protect-files.yml vendored Normal file
View File

@ -0,0 +1,55 @@
# Runs if there are changes to the paths: list.
# Starts a matrix job to check for modified files, then sets output based on the results.
# The input decides if the label job is ran, adding a label to the PR.
---
name: Protect Files
on:
pull_request:
types:
- opened
- synchronize
- unlabeled
paths:
- "util/Migrator/DbScripts/**.sql"
jobs:
changed-files:
name: Check for file changes
runs-on: ubuntu-20.04
outputs:
changes: ${{steps.check-changes.outputs.changes_detected}}
strategy:
fail-fast: true
matrix:
include:
- name: Database Scripts
path: util/Migrator/DbScripts
label: "DB-migrations-changed"
steps:
- name: Checkout repo
uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b
with:
fetch-depth: 2
- name: Check for file changes
id: check-changes
run: |
MODIFIED_FILES=$(git diff --name-only --diff-filter=M HEAD~1)
for file in $MODIFIED_FILES
do
if [[ $file == *"${{ matrix.path }}"* ]]; then
echo "changes_detected=true" >> $GITHUB_OUTPUT
break
else echo "changes_detected=false" >> $GITHUB_OUTPUT
fi
done
- name: Add label to pull request
if: contains(steps.check-changes.outputs.changes_detected, true)
uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90
with:
add-labels: ${{ matrix.label }}

View File

@ -1,137 +0,0 @@
---
name: QA Deploy
on:
workflow_dispatch:
inputs:
migrateDb:
required: true
default: "true"
resetDb:
required: true
default: "false"
jobs:
reset-db:
name: Reset Database
if: ${{ github.event.inputs.resetDb == 'true' }}
runs-on: ubuntu-20.04
steps:
- name: Reset Test Data - Stub
run: |
echo "placeholder for cleaning DB"
echo "placeholder for loading test dataset"
update-db:
name: Update Database
if: ${{ github.event.inputs.migrateDb == 'true' }}
runs-on: ubuntu-20.04
steps:
- name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
- name: Login to Azure
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: Azure/get-keyvault-secrets@80ccd3fafe5662407cc2e55f202ee34bfff8c403
with:
keyvault: "bitwarden-qa-kv"
secrets: "mssql-server-host,
mssql-admin-login,
mssql-admin-login-password"
- name: Migrate database
env:
MSSQL_HOST: ${{ steps.retrieve-secrets.outputs.mssql-server-host }}
MSSQL_USER: ${{ steps.retrieve-secrets.outputs.mssql-admin-login }}
MSSQL_PASS: ${{ steps.retrieve-secrets.outputs.mssql-admin-login-password }}
working-directory: ./util/Migrator/DbScripts
run: |
echo "Running database migrations..."
for f in `ls -v ./*.sql`; do
echo "Executing file: ${f}..."
sqlcmd -S $MSSQL_HOST -d vault -U $MSSQL_USER -P $MSSQL_PASS -I -i $f
done;
deploy:
name: Deploy
runs-on: ubuntu-20.04
if: always()
needs:
- reset-db
- update-db
strategy:
fail-fast: false
matrix:
include:
- name: Api
- name: Admin
- name: Billing
- name: Events
- name: Sso
- name: Identity
steps:
- name: Setup
id: setup
run: |
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.name }}"
echo "NAME_LOWER: $NAME_LOWER"
echo "::set-output name=name_lower::$NAME_LOWER"
BRANCH_NAME=$(echo "$GITHUB_REF" | sed "s#refs/heads/##g")
echo "GITHUB_REF: $GITHUB_REF"
echo "BRANCH_NAME: $BRANCH_NAME"
echo "::set-output name=branch_name::$BRANCH_NAME"
mkdir publish
- name: Download latest ${{ matrix.name }} asset from ${{ env.branch_name }}
uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783
env:
branch_name: ${{ steps.setup.outputs.branch_name }}
with:
workflow: build.yml
workflow_conclusion: success
branch: ${{ env.branch_name }}
artifacts: ${{ matrix.name }}.zip
- name: Login to Azure
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
env:
VAULT_NAME: "bitwarden-qa-kv"
run: |
webapp_name=$(
az keyvault secret show --vault-name $VAULT_NAME \
--name appservices-${{ steps.setup.outputs.name_lower }}-webapp-name \
--query value --output tsv
)
echo "::add-mask::$webapp_name"
echo "::set-output name=webapp-name::$webapp_name"
- name: Stop App Service
env:
AZURE_RESOURCE_GROUP: "bw-qa-env"
run: az webapp stop --name ${{ steps.retrieve-secrets.outputs.webapp-name }} --resource-group $AZURE_RESOURCE_GROUP
- name: Deploy App
uses: azure/webapps-deploy@798e43877120eda6a2a690a4f212c545e586ae31
with:
app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }}
package: ./${{ matrix.name }}.zip
- name: Start App Service
env:
AZURE_RESOURCE_GROUP: "bw-qa-env"
run: az webapp start --name ${{ steps.retrieve-secrets.outputs.webapp-name }} --resource-group $AZURE_RESOURCE_GROUP

View File

@ -1,56 +1,58 @@
--- ---
name: Release name: Release
run-name: Release ${{ inputs.release_type }}
on: on:
workflow_dispatch: workflow_dispatch:
inputs: {} inputs:
release_type:
description: "Release Options"
required: true
default: "Initial Release"
type: choice
options:
- Initial Release
- Redeploy
- Dry Run
jobs: jobs:
setup: setup:
name: Setup name: Setup
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
outputs: outputs:
release_version: ${{ steps.version.outputs.package }} release_version: ${{ steps.version.outputs.version }}
branch-name: ${{ steps.branch.outputs.branch-name }} branch-name: ${{ steps.branch.outputs.branch-name }}
steps: steps:
- name: Branch check - name: Branch check
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
run: | run: |
if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix" ]]; then if [[ "$GITHUB_REF" != "refs/heads/rc" ]] && [[ "$GITHUB_REF" != "refs/heads/hotfix-rc" ]]; then
echo "===================================" echo "==================================="
echo "[!] Can only release from the 'rc' or 'hotfix' branches" echo "[!] Can only release from the 'rc' or 'hotfix-rc' branches"
echo "===================================" echo "==================================="
exit 1 exit 1
fi fi
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
- name: Check Release Version - name: Check Release Version
id: version id: version
run: | uses: bitwarden/gh-actions/release-version-check@4cf17a5ff15a995a2daf2b60ba371e5c9907c068
version=$( grep -o "<Version>.*</Version>" Directory.Build.props | grep -o "[0-9]*\.[0-9]*\.[0-9]*") with:
previous_release_tag_version=$( release-type: ${{ github.event.inputs.release_type }}
curl -sL https://api.github.com/repos/$GITHUB_REPOSITORY/releases/latest | jq -r ".tag_name" project-type: dotnet
) file: Directory.Build.props
if [ "v$version" == "$previous_release_tag_version" ]; then
echo "[!] Already released v$version. Please bump version to continue"
exit 1
fi
echo "::set-output name=package::$version"
- name: Get branch name - name: Get branch name
id: branch id: branch
run: | run: |
BRANCH_NAME=$(basename ${{ github.ref }}) BRANCH_NAME=$(basename ${{ github.ref }})
echo "::set-output name=branch-name::$BRANCH_NAME" echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
deploy: deploy:
name: Deploy name: Deploy
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- setup - setup
strategy: strategy:
@ -70,18 +72,39 @@ jobs:
NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}') NAME_LOWER=$(echo "${{ matrix.name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.name }}" echo "Matrix name: ${{ matrix.name }}"
echo "NAME_LOWER: $NAME_LOWER" echo "NAME_LOWER: $NAME_LOWER"
echo "::set-output name=name_lower::$NAME_LOWER" echo "name_lower=$NAME_LOWER" >> $GITHUB_OUTPUT
- name: Create GitHub deployment for ${{ matrix.name }}
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: chrnorm/deployment-action@1b599fe41a0ef1f95191e7f2eec4743f2d7dfc48
id: deployment
with:
token: "${{ secrets.GITHUB_TOKEN }}"
initial-status: "in_progress"
environment: "Production Cloud"
task: "deploy"
description: "Deploy from ${{ needs.setup.outputs.branch-name }} branch"
- name: Download latest Release ${{ matrix.name }} asset - name: Download latest Release ${{ matrix.name }} asset
uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a
with: with:
workflow: build.yml workflow: build.yml
workflow_conclusion: success workflow_conclusion: success
branch: ${{ needs.setup.outputs.branch-name }} branch: ${{ needs.setup.outputs.branch-name }}
artifacts: ${{ matrix.name }}.zip artifacts: ${{ matrix.name }}.zip
- name: Download latest Release ${{ matrix.name }} asset
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a
with:
workflow: build.yml
workflow_conclusion: success
branch: master
artifacts: ${{ matrix.name }}.zip
- name: Login to Azure - name: Login to Azure
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with: with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
@ -101,22 +124,50 @@ jobs:
--query value --output tsv --query value --output tsv
) )
echo "::add-mask::$webapp_name" echo "::add-mask::$webapp_name"
echo "::set-output name=webapp-name::$webapp_name" echo "webapp-name=$webapp_name" >> $GITHUB_OUTPUT
echo "::add-mask::$publish_profile" echo "::add-mask::$publish_profile"
echo "::set-output name=publish-profile::$publish_profile" echo "publish-profile=$publish_profile" >> $GITHUB_OUTPUT
- name: Deploy App - name: Deploy App
uses: azure/webapps-deploy@798e43877120eda6a2a690a4f212c545e586ae31 uses: azure/webapps-deploy@0b651ed7546ecfc75024011f76944cb9b381ef1e
with: with:
app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }} app-name: ${{ steps.retrieve-secrets.outputs.webapp-name }}
publish-profile: ${{ steps.retrieve-secrets.outputs.publish-profile }} publish-profile: ${{ steps.retrieve-secrets.outputs.publish-profile }}
package: ./${{ matrix.name }}.zip package: ./${{ matrix.name }}.zip
slot-name: "staging" slot-name: "staging"
- name: Start staging slot
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
env:
SERVICE: ${{ matrix.name }}
WEBAPP_NAME: ${{ steps.retrieve-secrets.outputs.webapp-name }}
run: |
if [[ "$SERVICE" = "Api" ]] || [[ "$SERVICE" = "Identity" ]]; then
RESOURCE_GROUP=bitwardenappservices
else
RESOURCE_GROUP=bitwarden
fi
az webapp start -n $WEBAPP_NAME -g $RESOURCE_GROUP -s staging
- name: Update ${{ matrix.name }} deployment status to Success
if: ${{ github.event.inputs.release_type != 'Dry Run' && success() }}
uses: chrnorm/deployment-status@07b3930847f65e71c9c6802ff5a402f6dfb46b86
with:
token: "${{ secrets.GITHUB_TOKEN }}"
state: "success"
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
- name: Update ${{ matrix.name }} deployment status to Failure
if: ${{ github.event.inputs.release_type != 'Dry Run' && failure() }}
uses: chrnorm/deployment-status@07b3930847f65e71c9c6802ff5a402f6dfb46b86
with:
token: "${{ secrets.GITHUB_TOKEN }}"
state: "failure"
deployment-id: ${{ steps.deployment.outputs.deployment_id }}
release-docker: release-docker:
name: Build Docker images name: Build Docker images
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- setup - setup
env: env:
@ -126,94 +177,226 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- service_name: Admin - project_name: Admin
- service_name: Api origin_docker_repo: bitwarden
- service_name: Attachments - project_name: Api
- service_name: Events origin_docker_repo: bitwarden
- service_name: Icons - project_name: Attachments
- service_name: Identity origin_docker_repo: bitwarden
- service_name: K8S-Proxy - project_name: Events
- service_name: MsSql prod_acr: true
- service_name: Nginx origin_docker_repo: bitwarden
- service_name: Notifications - project_name: EventsProcessor
- service_name: Server prod_acr: true
- service_name: Setup origin_docker_repo: bitwardenqa.azurecr.io
- service_name: Sso - project_name: Icons
origin_docker_repo: bitwarden
prod_acr: true
- project_name: Identity
origin_docker_repo: bitwarden
- project_name: MsSql
origin_docker_repo: bitwarden
- project_name: Nginx
origin_docker_repo: bitwarden
- project_name: Notifications
origin_docker_repo: bitwarden
- project_name: Server
origin_docker_repo: bitwarden
- project_name: Setup
origin_docker_repo: bitwarden
- project_name: Sso
origin_docker_repo: bitwarden
- project_name: Scim
origin_docker_repo: bitwarden
- project_name: Billing
origin_docker_repo: bitwardenqa.azurecr.io
steps: steps:
- name: Print environment - name: Print environment
env:
RELEASE_OPTION: ${{ github.event.inputs.release_type }}
run: | run: |
whoami whoami
docker --version docker --version
echo "GitHub ref: $GITHUB_REF" echo "GitHub ref: $GITHUB_REF"
echo "GitHub event: $GITHUB_EVENT" echo "GitHub event: $GITHUB_EVENT"
echo "Github Release Option: $RELEASE_OPTION"
- name: Checkout repo
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
- name: Setup project name
id: setup
run: |
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
########## DockerHub ##########
- name: Setup DCT - name: Setup DCT
id: setup-dct id: setup-dct
if: matrix.origin_docker_repo == 'bitwarden'
uses: bitwarden/gh-actions/setup-docker-trust@a8c384a05a974c05c48374c818b004be221d43ff uses: bitwarden/gh-actions/setup-docker-trust@a8c384a05a974c05c48374c818b004be221d43ff
with: with:
azure-creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }} azure-creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
azure-keyvault-name: "bitwarden-prod-kv" azure-keyvault-name: "bitwarden-prod-kv"
- name: Checkout repo - name: Pull latest project image
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f if: matrix.origin_docker_repo == 'bitwarden'
- name: Setup service name
id: setup
run: |
SERVICE_NAME=$(echo "${{ matrix.service_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.service_name }}"
echo "SERVICE_NAME: $SERVICE_NAME"
echo "::set-output name=service_name::$SERVICE_NAME"
- name: Pull latest selfhost image
env: env:
SERVICE_NAME: ${{ steps.setup.outputs.service_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: docker pull bitwarden/$SERVICE_NAME:$_BRANCH_NAME run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker pull bitwarden/$PROJECT_NAME:latest
else
docker pull bitwarden/$PROJECT_NAME:$_BRANCH_NAME
fi
- name: Tag version and latest - name: Tag version and latest
if: matrix.origin_docker_repo == 'bitwarden'
env: env:
SERVICE_NAME: ${{ steps.setup.outputs.service_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
docker tag bitwarden/$SERVICE_NAME:$_BRANCH_NAME bitwarden/$SERVICE_NAME:$_RELEASE_VERSION if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker tag bitwarden/$SERVICE_NAME:$_BRANCH_NAME bitwarden/$SERVICE_NAME:latest docker tag bitwarden/$PROJECT_NAME:latest bitwarden/$PROJECT_NAME:dryrun
else
- name: List Docker images docker tag bitwarden/$PROJECT_NAME:$_BRANCH_NAME bitwarden/$PROJECT_NAME:$_RELEASE_VERSION
run: docker images fi
- name: Push version and latest image - name: Push version and latest image
if: ${{ github.event.inputs.release_type != 'Dry Run' && matrix.origin_docker_repo == 'bitwarden' }}
env: env:
DOCKER_CONTENT_TRUST: 1 DOCKER_CONTENT_TRUST: 1
DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ steps.setup-dct.outputs.dct-delegate-repo-passphrase }} DOCKER_CONTENT_TRUST_REPOSITORY_PASSPHRASE: ${{ steps.setup-dct.outputs.dct-delegate-repo-passphrase }}
SERVICE_NAME: ${{ steps.setup.outputs.service_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: docker push bitwarden/$PROJECT_NAME:$_RELEASE_VERSION
- name: Log out of Docker and disable Docker Notary
if: matrix.origin_docker_repo == 'bitwarden'
run: | run: |
docker push bitwarden/$SERVICE_NAME:$_RELEASE_VERSION docker logout
docker push bitwarden/$SERVICE_NAME:latest echo "DOCKER_CONTENT_TRUST=0" >> $GITHUB_ENV
########## ACR QA ##########
- name: Login to Azure - QA Subscription
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a
with:
creds: ${{ secrets.AZURE_QA_KV_CREDENTIALS }}
- name: Login to Azure ACR
run: az acr login -n bitwardenqa
- name: Pull latest project image
if: matrix.origin_docker_repo == 'bitwardenqa.azurecr.io'
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwardenqa.azurecr.io
run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker pull $REGISTRY/$PROJECT_NAME:latest
else
docker pull $REGISTRY/$PROJECT_NAME:$_BRANCH_NAME
fi
- name: Tag version and latest
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwardenqa.azurecr.io
ORIGIN_REGISTRY: ${{ matrix.origin_docker_repo }}
run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker tag $ORIGIN_REGISTRY/$PROJECT_NAME:latest $REGISTRY/$PROJECT_NAME:dryrun
else
docker tag $ORIGIN_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker tag $ORIGIN_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $REGISTRY/$PROJECT_NAME:latest
fi
- name: Push version and latest image
if: ${{ github.event.inputs.release_type != 'Dry Run' }}
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwardenqa.azurecr.io
run: |
docker push $REGISTRY/$PROJECT_NAME:latest
docker push $REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
- name: Log out of Docker - name: Log out of Docker
run: docker logout run: docker logout
########## ACR PROD ##########
- name: Login to Azure - PROD Subscription
if: matrix.prod_acr == true
uses: Azure/login@77f1b2e3fb80c0e8645114159d17008b8a2e475a
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Login to Azure ACR
if: matrix.prod_acr == true
run: az acr login -n bitwardenprod
- name: Tag version and latest
if: matrix.prod_acr == true
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwardenprod.azurecr.io
ORIGIN_REGISTRY: ${{ matrix.origin_docker_repo }}
run: |
if [[ "${{ github.event.inputs.release_type }}" == "Dry Run" ]]; then
docker tag $ORIGIN_REGISTRY/$PROJECT_NAME:latest $REGISTRY/$PROJECT_NAME:dryrun
else
docker tag $ORIGIN_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker tag $ORIGIN_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $REGISTRY/$PROJECT_NAME:latest
fi
- name: Push version and latest image
if: ${{ github.event.inputs.release_type != 'Dry Run' && matrix.prod_acr == true }}
env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
REGISTRY: bitwardenprod.azurecr.io
run: |
docker push $REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION
docker push $REGISTRY/$PROJECT_NAME:latest
- name: Log out of Docker
if: matrix.prod_acr == true
run: docker logout
release: release:
name: Create GitHub Release name: Create GitHub Release
runs-on: ubuntu-20.04 runs-on: ubuntu-22.04
needs: needs:
- setup - setup
- deploy - deploy
steps: steps:
- name: Download latest Release docker-stub - name: Download latest Release docker-stub
uses: bitwarden/gh-actions/download-artifacts@23433be15ed6fd046ce12b6889c5184a8d9c8783 if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a
with: with:
workflow: build.yml workflow: build.yml
workflow_conclusion: success workflow_conclusion: success
branch: ${{ needs.setup.outputs.branch-name }} branch: ${{ needs.setup.outputs.branch-name }}
artifacts: "docker-stub.zip, artifacts: "docker-stub.zip,
swagger.json" docker-stub-sha256.txt,
swagger.json"
- name: Download latest Release docker-stub
if: ${{ github.event.inputs.release_type == 'Dry Run' }}
uses: bitwarden/gh-actions/download-artifacts@850faad0cf6c02a8c0dc46eddde2363fbd6c373a
with:
workflow: build.yml
workflow_conclusion: success
branch: master
artifacts: "docker-stub.zip,
docker-stub-sha256.txt,
swagger.json"
- name: Create release - name: Create release
uses: ncipollo/release-action@95215a3cb6e6a1908b3c44e00b4fdb15548b1e09 if: ${{ github.event.inputs.release_type != 'Dry Run' }}
uses: ncipollo/release-action@40bb172bd05f266cf9ba4ff965cb61e9ee5f6d01
with: with:
artifacts: 'docker-stub.zip, artifacts: "docker-stub.zip,
swagger.json' docker-stub-sha256.txt,
swagger.json"
commit: ${{ github.sha }} commit: ${{ github.sha }}
tag: "v${{ needs.setup.outputs.release_version }}" tag: "v${{ needs.setup.outputs.release_version }}"
name: "Version ${{ needs.setup.outputs.release_version }}" name: "Version ${{ needs.setup.outputs.release_version }}"

30
.github/workflows/stale-bot.yml vendored Normal file
View File

@ -0,0 +1,30 @@
---
name: 'Close stale issues and PRs'
on:
workflow_dispatch:
schedule: # Run once a day at 5.23am (arbitrary but should avoid peak loads on the hour)
- cron: '23 5 * * *'
jobs:
stale:
name: 'Check for stale issues and PRs'
runs-on: ubuntu-20.04
steps:
- name: 'Run stale action'
uses: actions/stale@3cc123766321e9f15a6676375c154ccffb12a358 # v5.0.0
with:
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
close-issue-message: |
We need more information before we can help you with your problem. As we havent heard from you recently, this issue will be closed.
If this happens again or continues to be an problem, please respond to this issue with the information weve requested and anything else relevant.
close-pr-message: |
We cant merge your pull request until you make the changes weve requested. As we havent heard from you recently, this pull request will be closed.
If youre still working on this, please respond here after youve made the changes weve requested and our team will re-open it for further review.
Please make sure to resolve any conflicts with the master branch before requesting another review.

View File

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

93
.github/workflows/version-bump.yml vendored Normal file
View File

@ -0,0 +1,93 @@
---
name: Version Bump
on:
workflow_dispatch:
inputs:
version_number:
description: "New Version"
required: true
jobs:
bump_props_version:
name: "Create version_bump_${{ github.event.inputs.version_number }} branch"
runs-on: ubuntu-20.04
steps:
- name: Checkout Branch
uses: actions/checkout@ec3a7ce113134d7a93b817d10a8272cb61118579
- name: Login to Azure - Prod Subscription
uses: Azure/login@1f63701bf3e6892515f1b7ce2d2bf1708b46beaf
with:
creds: ${{ secrets.AZURE_PROD_KV_CREDENTIALS }}
- name: Retrieve secrets
id: retrieve-secrets
uses: bitwarden/gh-actions/get-keyvault-secrets@c3b3285993151c5af47cefcb3b9134c28ab479af
with:
keyvault: "bitwarden-prod-kv"
secrets: "github-gpg-private-key, github-gpg-private-key-passphrase"
- name: Import GPG key
uses: crazy-max/ghaction-import-gpg@c8bb57c57e8df1be8c73ff3d59deab1dbc00e0d1
with:
gpg_private_key: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key }}
passphrase: ${{ steps.retrieve-secrets.outputs.github-gpg-private-key-passphrase }}
git_user_signingkey: true
git_commit_gpgsign: true
- name: Create Version Branch
run: git switch -c version_bump_${{ github.event.inputs.version_number }}
- name: Bump Version - Props
uses: bitwarden/gh-actions/version-bump@6a42772f8849107fd457cf47cd9c7e224be44e55
with:
version: ${{ github.event.inputs.version_number }}
file_path: "Directory.Build.props"
- name: Setup git
run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot"
- name: Check if version changed
id: version-changed
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "changes_to_commit=TRUE" >> $GITHUB_OUTPUT
else
echo "changes_to_commit=FALSE" >> $GITHUB_OUTPUT
echo "No changes to commit!";
fi
- name: Commit files
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: git commit -m "Bumped version to ${{ github.event.inputs.version_number }}" -a
- name: Push changes
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
run: git push -u origin version_bump_${{ github.event.inputs.version_number }}
- name: Create Version PR
if: ${{ steps.version-changed.outputs.changes_to_commit == 'TRUE' }}
env:
PR_BRANCH: "version_bump_${{ github.event.inputs.version_number }}"
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
BASE_BRANCH: master
TITLE: "Bump version to ${{ github.event.inputs.version_number }}"
run: |
gh pr create --title "$TITLE" \
--base "$BASE" \
--head "$PR_BRANCH" \
--label "version update" \
--label "automated pr" \
--body "
## Type of change
- [ ] Bug fix
- [ ] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [X] Other
## Objective
Automated version bump to ${{ github.event.inputs.version_number }}"

11
.github/workflows/workflow-linter.yml vendored Normal file
View File

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

418
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,418 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"compounds": [
{
"name": "Min Server",
"configurations": [
"Identity",
"API"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 1
},
"stopAll": true
},
{
"name": "Admin, API, Identity",
"configurations": [
"Admin",
"API",
"Identity"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 3
},
"stopAll": true
},
{
"name": "Full Server",
"configurations": [
"Admin",
"API",
"EventsProcessor",
"Identity",
"Sso",
"Icons",
"Billing",
"Notifications"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 4
},
"stopAll": true
},
{
"name": "Self Host: Bit",
"configurations": [
"Admin-SelfHost",
"API-SelfHost",
"EventsProcessor-SelfHost",
"Identity-SelfHost",
"Sso-SelfHost",
"Notifications-SelfHost"
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 2
},
"stopAll": true
},
{
"name": "Self Host: OSS",
"configurations": [
"Admin-SelfHost",
"API-SelfHost",
"EventsProcessor-SelfHost",
"Identity-SelfHost",
],
"presentation": {
"hidden": false,
"group": "AA_compounds",
"order": 99
},
"stopAll": true
}
],
"configurations": [
{
"name": "Identity",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 10
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildIdentity",
"program": "${workspaceFolder}/src/Identity/bin/Debug/net6.0/Identity.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Identity",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "API",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 10
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildAPI",
"program": "${workspaceFolder}/src/Api/bin/Debug/net6.0/Api.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Api",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Billing",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 10
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildBilling",
"program": "${workspaceFolder}/src/Billing/bin/Debug/net6.0/Billing.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Billing",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Admin",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 20
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildAdmin",
"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",
"args": [],
"cwd": "${workspaceFolder}/src/Admin",
"stopAtEntry": false,
"OS-COMMENT5": "Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Sso",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 50
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildSso",
"program": "${workspaceFolder}/bitwarden_license/src/Sso/bin/Debug/net6.0/Sso.dll",
"args": [],
"cwd": "${workspaceFolder}/bitwarden_license/src/Sso",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "EventsProcessor",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 90
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildEventsProcessor",
"program": "${workspaceFolder}/src/EventsProcessor/bin/Debug/net6.0/EventsProcessor.dll",
"args": [],
"cwd": "${workspaceFolder}/src/EventsProcessor",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Icons",
"presentation": {
"hidden": false,
"group": "cloud",
"order": 90
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildIcons",
"program": "${workspaceFolder}/src/Icons/bin/Debug/net6.0/Icons.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Icons",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Notifications",
"presentation": {
"hidden": true,
"group": "cloud",
"order": 100
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildNotifications",
"program": "${workspaceFolder}/src/Notifications/bin/Debug/net6.0/Notifications.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Notifications",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Identity-SelfHost",
"presentation": {
"hidden": true,
"group": "self-host",
"order": 999
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildIdentity",
"program": "${workspaceFolder}/src/Identity/bin/Debug/net6.0/Identity.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Identity",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:33657",
"developSelfHosted": "true"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "API-SelfHost",
"presentation": {
"hidden": true,
"group": "self-host",
"order": 999
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildAPI",
"program": "${workspaceFolder}/src/Api/bin/Debug/net6.0/Api.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Api",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:4001",
"developSelfHosted": "true"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Admin-SelfHost",
"presentation": {
"hidden": true,
"group": "self-host",
"order": 999
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildAdmin",
"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",
"args": [],
"cwd": "${workspaceFolder}/src/Admin",
"stopAtEntry": false,
"OS-COMMENT5": "Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:62912",
"developSelfHosted": "true"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Sso-SelfHost",
"presentation": {
"hidden": true,
"group": "self-host",
"order": 999
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildSso",
"program": "${workspaceFolder}/bitwarden_license/src/Sso/bin/Debug/net6.0/Sso.dll",
"args": [],
"cwd": "${workspaceFolder}/bitwarden_license/src/Sso",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:51822",
"developSelfHosted": "true"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "Notifications-SelfHost",
"presentation": {
"hidden": true,
"group": "self-host",
"order": 999
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildNotifications",
"program": "${workspaceFolder}/src/Notifications/bin/Debug/net6.0/Notifications.dll",
"args": [],
"cwd": "${workspaceFolder}/src/Notifications",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:61841",
"developSelfHosted": "true"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": "EventsProcessor-SelfHost",
"presentation": {
"hidden": true,
"group": "self-host",
"order": 999
},
"requireExactSource": true,
"type": "coreclr",
"request": "launch",
"preLaunchTask": "buildEventsProcessor",
"program": "${workspaceFolder}/src/EventsProcessor/bin/Debug/net6.0/EventsProcessor.dll",
"args": [],
"cwd": "${workspaceFolder}/src/EventsProcessor",
"stopAtEntry": false,
"env": {
"ASPNETCORE_ENVIRONMENT": "Development",
"ASPNETCORE_URLS": "http://localhost:46274",
"developSelfHosted": "true"
},
"sourceFileMap": {
"/Views": "${workspaceFolder}/Views"
}
},
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach",
"processId": "${command:pickProcess}"
}
],
}

157
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,157 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "buildIcons",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Icons/Icons.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "buildPortal",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/bitwarden_license/src/Portal/Portal.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "buildSso",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/bitwarden_license/src/Sso/Sso.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "buildEventsProcessor",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/EventsProcessor/EventsProcessor.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "buildAdmin",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Admin/Admin.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "buildIdentity",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Identity/Identity.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "buildAPI",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Api/Api.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildNotifications",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Notifications/Notifications.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "buildBilling",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/Billing/Billing.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile",
"group": {
"kind": "build",
"isDefault": true
}
},
{
"label": "clean",
"type": "shell",
"command": "dotnet clean",
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"problemMatcher": "$msCompile"
},
{
"label": "test",
"type": "shell",
"command": "dotnet test",
"group": {
"kind": "test",
"isDefault": true
},
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"problemMatcher": "$msCompile"
}
]
}

View File

@ -1,31 +1,3 @@
# How to Contribute # How to Contribute
Contributions of all kinds are welcome! Our [Contributing Guidelines](https://contributing.bitwarden.com/contributing/) are located in our [Contributing Documentation](https://contributing.bitwarden.com/). The documentation also includes recommended tooling, code style tips, and lots of other great information to get you started.
Please visit our [Community Forums](https://community.bitwarden.com/) for general community discussion and the development roadmap.
Here is how you can get involved:
* **Request a new feature:** Go to the [Feature Requests category](https://community.bitwarden.com/c/feature-requests/) of the Community Forums. Please search existing feature requests before making a new one
* **Write code for a new feature:** Make a new post in the [Github Contributions category](https://community.bitwarden.com/c/github-contributions/) of the Community Forums. Include a description of your proposed contribution, screeshots, and links to any relevant feature requests. This helps get feedback from the community and Bitwarden team members before you start writing code
* **Report a bug or submit a bugfix:** Use Github issues and pull requests
* **Write documentation:** Submit a pull request to the [Bitwarden help repository](https://github.com/bitwarden/help)
* **Help other users:** Go to the [User-to-User Support category](https://community.bitwarden.com/c/support/) on the Community Forums
## Contributor Agreement
Please sign the [Contributor Agreement](https://cla-assistant.io/bitwarden/server) if you intend on contributing to any Github repository. Pull requests cannot be accepted and merged unless the author has signed the Contributor Agreement.
## Pull Request Guidelines
* commit any pull requests against the `master` branch
* include a link to your Community Forums post
* please do **not** submit version number updates/bumps
# Setup guide
Please read the [Setup guide](https://github.com/bitwarden/server/blob/master/SETUP.md) for a step-by-step guide to set up your own local development server.

View File

@ -1,9 +1,71 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<Version>1.44.1</Version> <!--2022.6.2-->
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <Version>2023.1.0</Version>
</PropertyGroup> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<ImplicitUsings>enable</ImplicitUsings>
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
</PropertyGroup>
<!--
This section is for packages that we use multiple times throughout the solution
It gives us a single place to manage the version to ensure we are using the same version
across the solution.
-->
<PropertyGroup>
<!--
NuGet: https://www.nuget.org/packages/Microsoft.NET.Test.Sdk
-->
<MicrosoftNetTestSdkVersion>17.1.0</MicrosoftNetTestSdkVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
-->
<XUnitVersion>2.4.1</XUnitVersion>
<!--
NuGet: https://www.nuget.org/packages/xunit
-->
<XUnitRunnerVisualStudioVersion>2.4.3</XUnitRunnerVisualStudioVersion>
<!--
NuGet: https://www.nuget.org/packages/coverlet.collector/
-->
<CoverletCollectorVersion>3.1.2</CoverletCollectorVersion>
<!--
NuGet: https://www.nuget.org/packages/Swashbuckle.AspNetCore/
-->
<MicrosoftVisualStudioWebCodeGenerationDesignVersion>6.0.3</MicrosoftVisualStudioWebCodeGenerationDesignVersion>
<!--
NuGet: https://www.nuget.org/packages/NSubstitute/
-->
<NSubstitueVersion>4.3.0</NSubstitueVersion>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.Xunit2/
-->
<AutoFixtureXUnit2Version>4.17.0</AutoFixtureXUnit2Version>
<!--
NuGet: https://www.nuget.org/packages/AutoFixture.AutoNSubstitute/
-->
<AutoFixtureAutoNSubstituteVersion>4.17.0</AutoFixtureAutoNSubstituteVersion>
</PropertyGroup>
<!--
This section is for getting & setting the gitHash value, which can easily be accessed
via the Core.Utilities.AssemblyHelpers class.
-->
<Target Name="SetSourceRevisionId" BeforeTargets="CoreGenerateAssemblyInfo">
<Exec Command="git describe --long --always --dirty --exclude=* --abbrev=8" ConsoleToMSBuild="True" IgnoreExitCode="False">
<Output PropertyName="SourceRevisionId" TaskParameter="ConsoleOutput"/>
</Exec>
</Target>
<Target Name="WriteRevision" AfterTargets="SetSourceRevisionId">
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>GitHash</_Parameter1>
<_Parameter2>$(SourceRevisionId)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Target>
</Project> </Project>

View File

@ -16,12 +16,12 @@ Our current software products have the following licenses:
*Bitwarden server:* The main Bitwarden server code is licensed under the AGPL 3.0 license. *Bitwarden server:* The main Bitwarden server code is licensed under the AGPL 3.0 license.
*CommCore and SSO integration:* Code for certain new modules that are designed and developed for use by larger *Commercial.Core and SSO integration:* Code for certain new modules that are designed and developed for use by larger
organizations and enterprise environments is released under the Bitwarden License, a "source available" license. The organizations and enterprise environments is released under the Bitwarden License, a "source available" license. The
Bitwarden License provides users access to product source code for non-production purposes such as development and Bitwarden License provides users access to product source code for non-production purposes such as development and
testing, but requires a paid subscription for production use of the product, and environments supporting production. testing, but requires a paid subscription for production use of the product, and environments supporting production.
Additionally the Api module by default includes CommCore which is under the Bitwarden License, however this can be Additionally the Api module by default includes Commercial.Core which is under the Bitwarden License, however this can
disabled by using `/p:DefineConstants="OSS"` as an argument to `dotnet` while building the module. be disabled by using `/p:DefineConstants="OSS"` as an argument to `dotnet` while building the module.
# Frequently Asked Questions # Frequently Asked Questions

View File

@ -13,51 +13,15 @@
</a> </a>
</p> </p>
------------------- ---
The Bitwarden Server project contains the APIs, database, and other core infrastructure items needed for the "backend" of all bitwarden client applications. The Bitwarden Server project contains the APIs, database, and other core infrastructure items needed for the "backend" of all bitwarden client applications.
The server project is written in C# using .NET Core with ASP.NET Core. The database is written in T-SQL/SQL Server. The codebase can be developed, built, run, and deployed cross-platform on Windows, macOS, and Linux distributions. The server project is written in C# using .NET Core with ASP.NET Core. The database is written in T-SQL/SQL Server. The codebase can be developed, built, run, and deployed cross-platform on Windows, macOS, and Linux distributions.
## Build/Run ## Developer Documentation
Please read the [Setup guide](https://github.com/bitwarden/server/blob/master/SETUP.md) for a step-by-step guide to set up your own local development server. Please refer to the [Server Setup Guide](https://contributing.bitwarden.com/getting-started/server/guide) in the [Contributing Documentation](https://contributing.bitwarden.com/) for build instructions, recommended tooling, code style tips, and lots of other great information to get you started.
### Requirements
- [.NET 5.0 SDK](https://dotnet.microsoft.com/download)
- [SQL Server 2017](https://docs.microsoft.com/en-us/sql/index)
*These dependencies are free to use.*
### Recommended Development Tooling
- [Visual Studio](https://www.visualstudio.com/vs/) (Windows and macOS)
- [Visual Studio Code](https://code.visualstudio.com/) (other)
*These tools are free to use.*
### API
```
cd src/Api
dotnet restore
dotnet build
dotnet run
```
visit http://localhost:4000/alive
### Identity
```
cd src/Identity
dotnet restore
dotnet build
dotnet run
```
visit http://localhost:33657/.well-known/openid-configuration
## Deploy ## Deploy
@ -76,13 +40,13 @@ Full documentation for deploying Bitwarden with Docker can be found in our help
- [Docker](https://www.docker.com/community-edition#/download) - [Docker](https://www.docker.com/community-edition#/download)
- [Docker Compose](https://docs.docker.com/compose/install/) (already included with some Docker installations) - [Docker Compose](https://docs.docker.com/compose/install/) (already included with some Docker installations)
*These dependencies are free to use.* _These dependencies are free to use._
### Linux & macOS ### Linux & macOS
``` ```
curl -s -o bitwarden.sh \ curl -s -L -o bitwarden.sh \
https://raw.githubusercontent.com/bitwarden/server/master/scripts/bitwarden.sh \ "https://func.bitwarden.com/api/dl/?app=self-host&platform=linux" \
&& chmod +x bitwarden.sh && chmod +x bitwarden.sh
./bitwarden.sh install ./bitwarden.sh install
./bitwarden.sh start ./bitwarden.sh start
@ -92,15 +56,40 @@ curl -s -o bitwarden.sh \
``` ```
Invoke-RestMethod -OutFile bitwarden.ps1 ` Invoke-RestMethod -OutFile bitwarden.ps1 `
-Uri https://raw.githubusercontent.com/bitwarden/server/master/scripts/bitwarden.ps1 -Uri "https://func.bitwarden.com/api/dl/?app=self-host&platform=windows"
.\bitwarden.ps1 -install .\bitwarden.ps1 -install
.\bitwarden.ps1 -start .\bitwarden.ps1 -start
``` ```
## We're Hiring!
Interested in contributing in a big way? Consider joining our team! We're hiring for many positions. Please take a look at our [Careers page](https://bitwarden.com/careers/) to see what opportunities are currently open as well as what it's like to work at Bitwarden.
## Contribute ## Contribute
Code contributions are welcome! Visual Studio or VS Code is highly recommended if you are working on this project. Please commit any pull requests against the `master` branch. Please see [`CONTRIBUTING.md`](CONTRIBUTING.md) for more info (and feel free to contribute to that guide as well). Code contributions are welcome! Please commit any pull requests against the `master` branch. Learn more about how to contribute by reading the [Contributing Guidelines](https://contributing.bitwarden.com/contributing/). Check out the [Contributing Documentation](https://contributing.bitwarden.com/) for how to get started with your first contribution.
Security audits and feedback are welcome. Please open an issue or email us privately if the report is sensitive in nature. You can read our security policy in the [`SECURITY.md`](SECURITY.md) file. We also run a program on [HackerOne](https://hackerone.com/bitwarden). Security audits and feedback are welcome. Please open an issue or email us privately if the report is sensitive in nature. You can read our security policy in the [`SECURITY.md`](SECURITY.md) file. We also run a program on [HackerOne](https://hackerone.com/bitwarden).
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). 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).
### Dotnet-format
Consider installing our git pre-commit hook for automatic formatting.
```bash
git config --local core.hooksPath .git-hooks
```
### File Scoped Namespaces
We recently migrated to using file scoped namespaces to save some horizontal space. All previous branches will need to update to avoid large merge conflicts using the following steps:
1. Check out your local Branch
2. Run `git merge 9b7aef0763ad14e229b337c3b5b27cb411009792`
3. Resolve any merge conflicts, commit.
4. Run `dotnet format`
5. Commit
6. Run `git merge -Xours 7f5f010e1eea400300c47f776604ecf46c4b4f2d`
7. Fix Merge conflicts
8. Push

View File

@ -1,39 +1,11 @@
Bitwarden believes that working with security researchers across the globe is crucial to keeping our Bitwarden believes that working with security researchers across the globe is crucial to keeping our users safe. If you believe you've found a security issue in our product or service, we encourage you to please submit a report through our [HackerOne Program](https://hackerone.com/bitwarden/). We welcome working with you to resolve the issue promptly. Thanks in advance!
users safe. If you believe you've found a security issue in our product or service, we encourage you to
notify us. We welcome working with you to resolve the issue promptly. Thanks in advance!
# Disclosure Policy # Disclosure Policy
- Let us know as soon as possible upon discovery of a potential security issue, and we'll make every - Let us know as soon as possible upon discovery of a potential security issue, and we'll make every effort to quickly resolve the issue.
effort to quickly resolve the issue. - Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a third-party. We may publicly disclose the issue before resolving it, if appropriate.
- Provide us a reasonable amount of time to resolve the issue before any disclosure to the public or a - Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our service. Only interact with accounts you own or with explicit permission of the account holder.
third-party. We may publicly disclose the issue before resolving it, if appropriate. - If you would like to encrypt your report, please use the PGP key with long ID `0xDE6887086F892325FEC04CC0D847525B6931381F` (available in the public keyserver pool).
- Make a good faith effort to avoid privacy violations, destruction of data, and interruption or
degradation of our service. Only interact with accounts you own or with explicit permission of the
account holder.
- If you would like to encrypt your report, please use the PGP key with long ID
`0xDE6887086F892325FEC04CC0D847525B6931381F` (available in the public keyserver pool).
# In-scope
- Security issues in any current release of Bitwarden. This includes the web vault, browser extension,
and mobile apps (iOS and Android). Product downloads are available at https://bitwarden.com. Source
code is available at https://github.com/bitwarden.
# Exclusions
The following bug classes are out-of scope:
- Bugs that are already reported on any of Bitwarden's issue trackers (https://github.com/bitwarden),
or that we already know of. Note that some of our issue tracking is private.
- Issues in an upstream software dependency (ex: Xamarin, ASP.NET) which are already reported to the
upstream maintainer.
- Attacks requiring physical access to a user's device.
- Self-XSS
- Issues related to software or protocols not under Bitwarden's control
- Vulnerabilities in outdated versions of Bitwarden
- Missing security best practices that do not directly lead to a vulnerability
- Issues that do not have any impact on the general public
While researching, we'd like to ask you to refrain from: While researching, we'd like to ask you to refrain from:
@ -42,4 +14,8 @@ While researching, we'd like to ask you to refrain from:
- Social engineering (including phishing) of Bitwarden staff or contractors - Social engineering (including phishing) of Bitwarden staff or contractors
- Any physical attempts against Bitwarden property or data centers - Any physical attempts against Bitwarden property or data centers
# We want to help you!
If you have something that you feel is close to exploitation, or if you'd like some information regarding the internal API, or generally have any questions regarding the app that would help in your efforts, please email us at https://bitwarden.com/contact and ask for that information. As stated above, Bitwarden wants to help you find issues, and is more than willing to help.
Thank you for helping keep Bitwarden and our users safe! Thank you for helping keep Bitwarden and our users safe!

197
SETUP.md
View File

@ -1,197 +0,0 @@
# Server Architecture
The Server is divided into a number of services. Each service is a Visual Studio project in the Server solution. These are:
* Admin
* Api
* Icons
* Identity
* Notifications
* SQL
Each service is built and run separately. The Bitwarden clients can use different servers for different services.
This means that you don't need to run all services locally for a development environment. You can run only those services that you intend to modify, and use Bitwarden.com or a self-hosted instance for all other services required.
By default some of the services depends on the Bitwarden licensed `CommCore`, however it can easily be disabled by including the `/p:DefineConstants="OSS"` as an argument to `dotnet`.
# Local Development Environment Setup
This guide will show you how to set up the Api, Identity and SQL projects for development. These are the minimum projects for any development work. You may need to set up additional projects depending on the changes you want to make.
We recommend using [Visual Studio](https://visualstudio.microsoft.com/vs/), and [PowerShell](https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell?view=powershell-7.1) which is used for the helper scripts.
## Docker containers
To simplify the setup process we provide a [Docker Compose](https://docs.docker.com/compose/) application model. This is split up into multiple service profiles to facilitate easily customization.
Some settings can be customized by modifying the `dev/.env` file, such as the `MSSQL_PASSWORD` which should be modified before starting the project.
```bash
# Copy the example environment file
cp ./.env.example ./.env
# We recommend running the following command when developing for self-hosted
docker compose --profile mssql --profile mail up
# We also provide a storage profile which uses Azurite to emulate some services used by the cloud instance
# Usually only needed by internal Bitwarden developers
docker compose --profile cloud --profile mail up
```
### SQL Server
We recommend changing the `MSSQL_PASSWORD` variable in `dev/.env` to avoid exposing the sqlserver with a default password. **Note**: changing this after first running docker compose may require a re-creation of the storage volume. To do this, stop the running containers and run `docker volume rm bitwardenserver_mssql_dev_data`. (**Warning:** this will delete your development database.)
We provide a helper script which will create the development database `vault_dev` and also run all migrations. This command should be run after starting docker the first time, as well as after syncing against upstream and after creating a new migration.
```powershell
.\dev\migrate.ps1
# You can also re-run the last migration using
.\dev\migrate.ps1 -r
```
**Note:** If all or many migrations are skipped even though this is a new database, make sure that there is not a `last_migration` file located in `dev/.data/mssql`. If there is, remove it and run the helper script again. This can happen if you create the database, run migrations, then delete it.
### Azurite
[Azurite](https://github.com/Azure/Azurite) is a emulator for Azure Storage API and supports Blob, Queues and Table storage. We use it to avoid a hard dependency on online services for cloud development.
To bootstrap the local Azurite instance please run the following command:
```powershell
# This script requires the Az module, which can be installed using
Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force
.\dev\setup_azurite.ps1
```
### Mailcatcher
Since the server uses emails for many user interactions a working SMTP server is a requirement, we provide a pre-setup instance of [MailCatcher](https://mailcatcher.me/) which exposes a web interface at http://localhost:1080.
## Certificates
In order to run Bitwarden, we require two certificates which for local development can be resolved by using self signed certificates.
### Windows
We provide a helper script which will generate and add the certificates to the users Certificate Store. After running the script it will output the thumbprints needed for the next step. The certificates can later be accessed using `certml.msc` under `Personal/Certificates`.
```powershell
.\create_certificates_windows.ps1
PSParentPath: Microsoft.PowerShell.Security\Certificate::CurrentUser\My
Thumbprint Subject
---------- -------
0BE8A0072214AB37C6928968752F698EEC3A68B5 CN=Bitwarden Identity Server Dev
C3A6CECAD3DB580F91A52FC9C767FE780300D8AB CN=Bitwarden Data Protection Dev
```
### MacOS
We provide a helper script which will generate the certificates and add them to the keychain.
**Note:** You should update the Trust options for each certificate to `always trust` using *Keychain Access*.
```bash
./create_certificates_mac.sh
Certificate fingerprints:
Identity Server Dev: 0BE8A0072214AB37C6928968752F698EEC3A68B5
Data Protection Dev: C3A6CECAD3DB580F91A52FC9C767FE780300D8AB
```
## User Secrets
User secrets are a method for managing application settings on a per-developer basis. They are stored outside of the local git repository so that they are not pushed to remote.
User secrets override the settings in `appsettings.json` of each project. Your user secrets file should match the structure of the `appsettings.json` file for the settings you intend to override.
For more information, see: [Safe storage of app secrets in development in ASP.NET Core](https://docs.microsoft.com/en-us/aspnet/core/security/app-secrets?view=aspnetcore-5.0).
### Automated Helper script
We provide a helper scripts which simplifies setting user secrets for all projects in the repository.
Start by copying the `secret.json.example` file to `secrets.json` and modify the existing settings and add any other required setting. Afterwards run the following command which will add the settings to each project in the bitwarden repository.
```powershell
.\setup_secrets.ps1
# The script also supports an optional flag which removes all existing settings before re-applying them
.\setup_secrets.ps1 -clear 1
```
### Manually creating and modifying
It is also possible to manually create and modify the user secrets using either the `dotnet` CLI or `Visual Studio` on Windows. For more details see [Appendix A](#user-secrets).
### Required User Secrets
**selfhosted**: It is highly recommended that you use the `selfHosted: true` setting when running a local development environment. This tells the system not to use cloud services, assuming that you are running your own local SQL instance.
**sqlServer__connectionString**: this provides the information required for the Server to connect to the SQL instance. See the example connection string in `secrets.json.example`. You may need to change the default password in the connection string.
**licenseDirectory**: this must be set to avoid errors, but it can be set to an arbitrary empty folder.
**installation__key** and **installation__id**: request your own private Installation Id and Installation Key for self-hosting: https://bitwarden.com/host/.
## Running and Debugging
After you have completed the above steps, you should be ready to launch your development environment for the Api and Identity projects.
### Visual Studio
To debug:
* On Windows, right-click on each project > click **Debug** > click **Start New Instance**
* On MacOS, right-click each project > click **Start Debugging Project**
To run without debugging, open a terminal and navigate to the location of the .csproj file for that project (usually in `src/ProjectName`). Start the project with:
```bash
dotnet run
```
NOTE: check the output of the running project to find the port it is listening on. If this is different to the default in `appsettings.json`, you may need to update your user secrets to override this (typically the Api user secrets for the Identity URL).
### Rider
From within Rider, launch both the Api project and the Identity project by clicking the "play" button for each project separately.
### Testing your deployment
* To test the deployment of each project, navigate to the following pages in your browser. You should see server output and no errors:
* Test the Api deployment: http://localhost:4000/alive
* Test the Identity deployment: http://localhost:33656/.well-known/openid-configuration
* If your test was successful, you can connect a GUI client to the dev environment by following the instructions here: [Change your client application's environment](https://bitwarden.com/help/article/change-client-environment/). If you are following this guide, you should only set the API Server URL and Identity Server URL to localhost:port and leave all other fields blank.
* If you are using the CLI client, you will also need to set the Node environment variables for your self-signed certificates by following the instructions here: [The Bitwarden command-line tool (CLI) > Self-signed certificates](https://bitwarden.com/help/article/cli/#self-signed-certificates).
### Troubleshooting
* If you get a 404 error, the projects may be listening on a non-default port. Check the output of your running projects to check the port they are listening on.
# <a name="user-secrets"></a>Appendix A (User Secrets)
### Editing user secrets - Visual Studio on Windows
Right-click on the project in the Solution Explorer and click **Manage User Secrets**.
### Editing user secrets - Visual Studio on macOS
Open a terminal and navigate to the project directory. Once there, initiate and create the blank user secrets file by running:
```bash
dotnet user-secrets init
```
Add a user secret by running:
```bash
dotnet user-secrets set "<key>" "<value>"
```
View currently set secrets by running:
```bash
dotnet user-secrets list
```
By default, user secret files are located in `~/.microsoft/usersecrets/<project name>/secrets.json`. After the file has been created, you can edit this directly with VSCode, which is much easier than using the command line tool.
### Editing user secrets - Rider
* Navigate to **Preferences -> Plugins** and Install .NET Core User Secrets
* Right click on the a project and click **Tools** > **Open project user secrets**

View File

@ -61,18 +61,56 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sso", "bitwarden_license\sr
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icons.Test", "test\Icons.Test\Icons.Test.csproj", "{C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Icons.Test", "test\Icons.Test\Icons.Test.csproj", "{C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommCore", "bitwarden_license\src\CommCore\CommCore.csproj", "{EDC0D688-D58C-4CE1-AA07-3606AC6874B8}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Commercial.Core", "bitwarden_license\src\Commercial.Core\Commercial.Core.csproj", "{EDC0D688-D58C-4CE1-AA07-3606AC6874B8}"
ProjectSection(ProjectDependencies) = postProject ProjectSection(ProjectDependencies) = postProject
{3973D21B-A692-4B60-9B70-3631C057423A} = {3973D21B-A692-4B60-9B70-3631C057423A} {3973D21B-A692-4B60-9B70-3631C057423A} = {3973D21B-A692-4B60-9B70-3631C057423A}
EndProjectSection EndProjectSection
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CommCore.Test", "bitwarden_license\test\CmmCore.Test\CommCore.Test.csproj", "{0E99A21B-684B-4C59-9831-90F775CAB6F7}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Commercial.Core.Test", "bitwarden_license\test\Commercial.Core.Test\Commercial.Core.Test.csproj", "{0E99A21B-684B-4C59-9831-90F775CAB6F7}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test - Bitwarden License", "test - Bitwarden License", "{287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test - Bitwarden License", "test - Bitwarden License", "{287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MySqlMigrations", "util\MySqlMigrations\MySqlMigrations.csproj", "{BDC1D592-5947-47ED-9903-7CDBB12A50C8}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MySqlMigrations", "util\MySqlMigrations\MySqlMigrations.csproj", "{BDC1D592-5947-47ED-9903-7CDBB12A50C8}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostgresMigrations", "util\PostgresMigrations\PostgresMigrations.csproj", "{F72E0229-2EF7-49B3-9004-FF4C0043816E}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PostgresMigrations", "util\PostgresMigrations\PostgresMigrations.csproj", "{F72E0229-2EF7-49B3-9004-FF4C0043816E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "test\Common\Common.csproj", "{17DA09D7-0212-4009-879E-6B9CFDE5FA60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.Dapper", "src\Infrastructure.Dapper\Infrastructure.Dapper.csproj", "{AD933445-27CE-4D30-A6ED-9065309464AD}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharedWeb", "src\SharedWeb\SharedWeb.csproj", "{713D44C0-1BC1-4024-96A3-A98A49F33908}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Infrastructure.EntityFramework", "src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj", "{ED880735-0250-43C7-9662-FDC7C7416E7F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Billing.Test", "test\Billing.Test\Billing.Test.csproj", "{B8639B10-2157-44BC-8CE1-D9EB4B50971F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Identity.Test", "test\Identity.Test\Identity.Test.csproj", "{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Identity.IntegrationTest", "test\Identity.IntegrationTest\Identity.IntegrationTest.csproj", "{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "IntegrationTestCommon", "test\IntegrationTestCommon\IntegrationTestCommon.csproj", "{0923DE59-5FB1-44F2-9302-A09D2236B470}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Scim", "bitwarden_license\src\Scim\Scim.csproj", "{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqlServerEFScaffold", "util\SqlServerEFScaffold\SqlServerEFScaffold.csproj", "{2F2E8BB0-6838-48DA-B581-71B9F13DE364}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Commercial.Infrastructure.EntityFramework", "bitwarden_license\src\Commercial.Infrastructure.EntityFramework\Commercial.Infrastructure.EntityFramework.csproj", "{5AB3BBFB-9D98-4EF8-BFCD-462D50A16EB1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.EFIntegration.Test", "test\Infrastructure.EFIntegration.Test\Infrastructure.EFIntegration.Test.csproj", "{7EFB1124-F40A-40EB-9EDA-94FD540AA8FD}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Api.IntegrationTest", "test\Api.IntegrationTest\Api.IntegrationTest.csproj", "{CBE96C6D-A4D6-46E1-94C5-42D6CAD8531C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim.IntegrationTest", "bitwarden_license\test\Scim.IntegrationTest\Scim.IntegrationTest.csproj", "{FE998849-5FC8-41A2-B7C9-9227901471A0}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "perf", "perf", "{EC2D422A-6060-48E2-AAD2-37220D759F03}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MicroBenchmarks", "perf\MicroBenchmarks\MicroBenchmarks.csproj", "{9C8F8255-5F74-4085-AB9C-9075CF6DDC61}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Scim.Test", "bitwarden_license\test\Scim.Test\Scim.Test.csproj", "{B1595DA3-4C60-41AA-8BF0-499A5F75A885}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure.IntegrationTest", "test\Infrastructure.IntegrationTest\Infrastructure.IntegrationTest.csproj", "{7E9A7DD5-EB78-4AC5-BFD5-64573FD2533B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SqliteMigrations", "util\SqliteMigrations\SqliteMigrations.csproj", "{07143DFA-F242-47A4-A15E-39C9314D4140}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -150,15 +188,6 @@ Global
{C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}.Release|Any CPU.Build.0 = Release|Any CPU {C7BA2255-C1B1-4789-8BB9-C27540DA6FB8}.Release|Any CPU.Build.0 = Release|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Release|Any CPU.Build.0 = Release|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Release|Any CPU.Build.0 = Release|Any CPU
{EDC0D688-D58C-4CE1-AA07-3606AC6874B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EDC0D688-D58C-4CE1-AA07-3606AC6874B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EDC0D688-D58C-4CE1-AA07-3606AC6874B8}.Debug|Any CPU.Build.0 = Debug|Any CPU {EDC0D688-D58C-4CE1-AA07-3606AC6874B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EDC0D688-D58C-4CE1-AA07-3606AC6874B8}.Release|Any CPU.ActiveCfg = Release|Any CPU {EDC0D688-D58C-4CE1-AA07-3606AC6874B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
@ -167,6 +196,86 @@ Global
{0E99A21B-684B-4C59-9831-90F775CAB6F7}.Debug|Any CPU.Build.0 = Debug|Any CPU {0E99A21B-684B-4C59-9831-90F775CAB6F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E99A21B-684B-4C59-9831-90F775CAB6F7}.Release|Any CPU.ActiveCfg = Release|Any CPU {0E99A21B-684B-4C59-9831-90F775CAB6F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E99A21B-684B-4C59-9831-90F775CAB6F7}.Release|Any CPU.Build.0 = Release|Any CPU {0E99A21B-684B-4C59-9831-90F775CAB6F7}.Release|Any CPU.Build.0 = Release|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BDC1D592-5947-47ED-9903-7CDBB12A50C8}.Release|Any CPU.Build.0 = Release|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F72E0229-2EF7-49B3-9004-FF4C0043816E}.Release|Any CPU.Build.0 = Release|Any CPU
{17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17DA09D7-0212-4009-879E-6B9CFDE5FA60}.Release|Any CPU.Build.0 = Release|Any CPU
{AD933445-27CE-4D30-A6ED-9065309464AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD933445-27CE-4D30-A6ED-9065309464AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD933445-27CE-4D30-A6ED-9065309464AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD933445-27CE-4D30-A6ED-9065309464AD}.Release|Any CPU.Build.0 = Release|Any CPU
{713D44C0-1BC1-4024-96A3-A98A49F33908}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{713D44C0-1BC1-4024-96A3-A98A49F33908}.Debug|Any CPU.Build.0 = Debug|Any CPU
{713D44C0-1BC1-4024-96A3-A98A49F33908}.Release|Any CPU.ActiveCfg = Release|Any CPU
{713D44C0-1BC1-4024-96A3-A98A49F33908}.Release|Any CPU.Build.0 = Release|Any CPU
{ED880735-0250-43C7-9662-FDC7C7416E7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ED880735-0250-43C7-9662-FDC7C7416E7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ED880735-0250-43C7-9662-FDC7C7416E7F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ED880735-0250-43C7-9662-FDC7C7416E7F}.Release|Any CPU.Build.0 = Release|Any CPU
{B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B8639B10-2157-44BC-8CE1-D9EB4B50971F}.Release|Any CPU.Build.0 = Release|Any CPU
{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E}.Release|Any CPU.Build.0 = Release|Any CPU
{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0D3B2BD2-53F3-421D-AD8F-C19B954C796B}.Release|Any CPU.Build.0 = Release|Any CPU
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0923DE59-5FB1-44F2-9302-A09D2236B470}.Release|Any CPU.Build.0 = Release|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747}.Release|Any CPU.Build.0 = Release|Any CPU
{2F2E8BB0-6838-48DA-B581-71B9F13DE364}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2F2E8BB0-6838-48DA-B581-71B9F13DE364}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2F2E8BB0-6838-48DA-B581-71B9F13DE364}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2F2E8BB0-6838-48DA-B581-71B9F13DE364}.Release|Any CPU.Build.0 = Release|Any CPU
{5AB3BBFB-9D98-4EF8-BFCD-462D50A16EB1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5AB3BBFB-9D98-4EF8-BFCD-462D50A16EB1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5AB3BBFB-9D98-4EF8-BFCD-462D50A16EB1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5AB3BBFB-9D98-4EF8-BFCD-462D50A16EB1}.Release|Any CPU.Build.0 = Release|Any CPU
{7EFB1124-F40A-40EB-9EDA-94FD540AA8FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EFB1124-F40A-40EB-9EDA-94FD540AA8FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EFB1124-F40A-40EB-9EDA-94FD540AA8FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EFB1124-F40A-40EB-9EDA-94FD540AA8FD}.Release|Any CPU.Build.0 = Release|Any CPU
{CBE96C6D-A4D6-46E1-94C5-42D6CAD8531C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBE96C6D-A4D6-46E1-94C5-42D6CAD8531C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBE96C6D-A4D6-46E1-94C5-42D6CAD8531C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBE96C6D-A4D6-46E1-94C5-42D6CAD8531C}.Release|Any CPU.Build.0 = Release|Any CPU
{FE998849-5FC8-41A2-B7C9-9227901471A0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FE998849-5FC8-41A2-B7C9-9227901471A0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FE998849-5FC8-41A2-B7C9-9227901471A0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FE998849-5FC8-41A2-B7C9-9227901471A0}.Release|Any CPU.Build.0 = Release|Any CPU
{9C8F8255-5F74-4085-AB9C-9075CF6DDC61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9C8F8255-5F74-4085-AB9C-9075CF6DDC61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9C8F8255-5F74-4085-AB9C-9075CF6DDC61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9C8F8255-5F74-4085-AB9C-9075CF6DDC61}.Release|Any CPU.Build.0 = Release|Any CPU
{B1595DA3-4C60-41AA-8BF0-499A5F75A885}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B1595DA3-4C60-41AA-8BF0-499A5F75A885}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B1595DA3-4C60-41AA-8BF0-499A5F75A885}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B1595DA3-4C60-41AA-8BF0-499A5F75A885}.Release|Any CPU.Build.0 = Release|Any CPU
{7E9A7DD5-EB78-4AC5-BFD5-64573FD2533B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E9A7DD5-EB78-4AC5-BFD5-64573FD2533B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E9A7DD5-EB78-4AC5-BFD5-64573FD2533B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7E9A7DD5-EB78-4AC5-BFD5-64573FD2533B}.Release|Any CPU.Build.0 = Release|Any CPU
{07143DFA-F242-47A4-A15E-39C9314D4140}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{07143DFA-F242-47A4-A15E-39C9314D4140}.Debug|Any CPU.Build.0 = Debug|Any CPU
{07143DFA-F242-47A4-A15E-39C9314D4140}.Release|Any CPU.ActiveCfg = Release|Any CPU
{07143DFA-F242-47A4-A15E-39C9314D4140}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -189,10 +298,28 @@ Global
{860DE301-0B3E-4717-9C21-A9B4C3C2B121} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {860DE301-0B3E-4717-9C21-A9B4C3C2B121} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{4866AF64-6640-4C65-A662-A31E02FF9064} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A} {4866AF64-6640-4C65-A662-A31E02FF9064} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A}
{C7BA2255-C1B1-4789-8BB9-C27540DA6FB8} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {C7BA2255-C1B1-4789-8BB9-C27540DA6FB8} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{BDC1D592-5947-47ED-9903-7CDBB12A50C8} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{F72E0229-2EF7-49B3-9004-FF4C0043816E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{EDC0D688-D58C-4CE1-AA07-3606AC6874B8} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A} {EDC0D688-D58C-4CE1-AA07-3606AC6874B8} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A}
{0E99A21B-684B-4C59-9831-90F775CAB6F7} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B} {0E99A21B-684B-4C59-9831-90F775CAB6F7} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{BDC1D592-5947-47ED-9903-7CDBB12A50C8} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{F72E0229-2EF7-49B3-9004-FF4C0043816E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{17DA09D7-0212-4009-879E-6B9CFDE5FA60} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{AD933445-27CE-4D30-A6ED-9065309464AD} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}
{713D44C0-1BC1-4024-96A3-A98A49F33908} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}
{ED880735-0250-43C7-9662-FDC7C7416E7F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84D}
{B8639B10-2157-44BC-8CE1-D9EB4B50971F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{310A1D8E-2D3F-4FA0-84D4-FFE31FCE193E} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{0D3B2BD2-53F3-421D-AD8F-C19B954C796B} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{0923DE59-5FB1-44F2-9302-A09D2236B470} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{BC3B3F8C-621A-4CB8-9563-6EC0A2C8C747} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A}
{2F2E8BB0-6838-48DA-B581-71B9F13DE364} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{5AB3BBFB-9D98-4EF8-BFCD-462D50A16EB1} = {4FDB6543-F68B-4202-9EA6-7FEA984D2D0A}
{7EFB1124-F40A-40EB-9EDA-94FD540AA8FD} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{CBE96C6D-A4D6-46E1-94C5-42D6CAD8531C} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{FE998849-5FC8-41A2-B7C9-9227901471A0} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{9C8F8255-5F74-4085-AB9C-9075CF6DDC61} = {EC2D422A-6060-48E2-AAD2-37220D759F03}
{B1595DA3-4C60-41AA-8BF0-499A5F75A885} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
{7E9A7DD5-EB78-4AC5-BFD5-64573FD2533B} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{07143DFA-F242-47A4-A15E-39C9314D4140} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@ -1,513 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Enums.Provider;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Business.Provider;
using Bit.Core.Models.Data;
using Bit.Core.Models.Table;
using Bit.Core.Models.Table.Provider;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
namespace Bit.CommCore.Services
{
public class ProviderService : IProviderService
{
public static PlanType[] ProviderDisllowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 };
private readonly IDataProtector _dataProtector;
private readonly IMailService _mailService;
private readonly IEventService _eventService;
private readonly GlobalSettings _globalSettings;
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly IOrganizationService _organizationService;
private readonly ICurrentContext _currentContext;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
IUserService userService, IOrganizationService organizationService, IMailService mailService,
IDataProtectionProvider dataProtectionProvider, IEventService eventService,
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_organizationRepository = organizationRepository;
_userRepository = userRepository;
_userService = userService;
_organizationService = organizationService;
_mailService = mailService;
_eventService = eventService;
_globalSettings = globalSettings;
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
_currentContext = currentContext;
}
public async Task CreateAsync(string ownerEmail)
{
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null)
{
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
}
var provider = new Provider
{
Status = ProviderStatusType.Pending,
Enabled = true,
UseEvents = true,
};
await _providerRepository.CreateAsync(provider);
var providerUser = new ProviderUser
{
ProviderId = provider.Id,
UserId = owner.Id,
Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Confirmed,
};
await _providerUserRepository.CreateAsync(providerUser);
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null)
{
throw new BadRequestException("Invalid owner.");
}
if (provider.Status != ProviderStatusType.Pending)
{
throw new BadRequestException("Provider is already setup.");
}
if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id,
_globalSettings.OrganizationInviteExpirationHours))
{
throw new BadRequestException("Invalid token.");
}
var providerUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, ownerUserId);
if (!(providerUser is { Type: ProviderUserType.ProviderAdmin }))
{
throw new BadRequestException("Invalid owner.");
}
provider.Status = ProviderStatusType.Created;
await _providerRepository.UpsertAsync(provider);
providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser);
return provider;
}
public async Task UpdateAsync(Provider provider, bool updateBilling = false)
{
if (provider.Id == default)
{
throw new ArgumentException("Cannot create provider this way.");
}
await _providerRepository.ReplaceAsync(provider);
}
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
{
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
{
throw new InvalidOperationException("Invalid permissions.");
}
var emails = invite?.UserIdentifiers;
var invitingUser = await _providerUserRepository.GetByProviderUserAsync(invite.ProviderId, invite.InvitingUserId);
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
if (provider == null || emails == null || !emails.Any())
{
throw new NotFoundException();
}
var providerUsers = new List<ProviderUser>();
foreach (var email in emails)
{
// Make sure user is not already invited
var existingProviderUserCount =
await _providerUserRepository.GetCountByProviderAsync(invite.ProviderId, email, false);
if (existingProviderUserCount > 0)
{
continue;
}
var providerUser = new ProviderUser
{
ProviderId = invite.ProviderId,
UserId = null,
Email = email.ToLowerInvariant(),
Key = null,
Type = invite.Type,
Status = ProviderUserStatusType.Invited,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
await _providerUserRepository.CreateAsync(providerUser);
await SendInviteAsync(providerUser, provider);
providerUsers.Add(providerUser);
}
await _eventService.LogProviderUsersEventAsync(providerUsers.Select(pu => (pu, EventType.ProviderUser_Invited, null as DateTime?)));
return providerUsers;
}
public async Task<List<Tuple<ProviderUser, string>>> ResendInvitesAsync(ProviderUserInvite<Guid> invite)
{
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
{
throw new BadRequestException("Invalid permissions.");
}
var providerUsers = await _providerUserRepository.GetManyAsync(invite.UserIdentifiers);
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
var result = new List<Tuple<ProviderUser, string>>();
foreach (var providerUser in providerUsers)
{
if (providerUser.Status != ProviderUserStatusType.Invited || providerUser.ProviderId != invite.ProviderId)
{
result.Add(Tuple.Create(providerUser, "User invalid."));
continue;
}
await SendInviteAsync(providerUser, provider);
result.Add(Tuple.Create(providerUser, ""));
}
return result;
}
public async Task<ProviderUser> AcceptUserAsync(Guid providerUserId, User user, string token)
{
var providerUser = await _providerUserRepository.GetByIdAsync(providerUserId);
if (providerUser == null)
{
throw new BadRequestException("User invalid.");
}
if (providerUser.Status != ProviderUserStatusType.Invited)
{
throw new BadRequestException("Already accepted.");
}
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id,
_globalSettings.OrganizationInviteExpirationHours))
{
throw new BadRequestException("Invalid token.");
}
if (string.IsNullOrWhiteSpace(providerUser.Email) ||
!providerUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
providerUser.Status = ProviderUserStatusType.Accepted;
providerUser.UserId = user.Id;
providerUser.Email = null;
await _providerUserRepository.ReplaceAsync(providerUser);
return providerUser;
}
public async Task<List<Tuple<ProviderUser, string>>> ConfirmUsersAsync(Guid providerId, Dictionary<Guid, string> keys,
Guid confirmingUserId)
{
var providerUsers = await _providerUserRepository.GetManyAsync(keys.Keys);
var validProviderUsers = providerUsers
.Where(u => u.UserId != null)
.ToList();
if (!validProviderUsers.Any())
{
return new List<Tuple<ProviderUser, string>>();
}
var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList();
var provider = await _providerRepository.GetByIdAsync(providerId);
var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
var keyedFilteredUsers = validProviderUsers.ToDictionary(u => u.UserId.Value, u => u);
var result = new List<Tuple<ProviderUser, string>>();
var events = new List<(ProviderUser, EventType, DateTime?)>();
foreach (var user in users)
{
if (!keyedFilteredUsers.ContainsKey(user.Id))
{
continue;
}
var providerUser = keyedFilteredUsers[user.Id];
try
{
if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId)
{
throw new BadRequestException("Invalid user.");
}
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Key = keys[providerUser.Id];
providerUser.Email = null;
await _providerUserRepository.ReplaceAsync(providerUser);
events.Add((providerUser, EventType.ProviderUser_Confirmed, null));
await _mailService.SendProviderConfirmedEmailAsync(provider.Name, user.Email);
result.Add(Tuple.Create(providerUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(providerUser, e.Message));
}
}
await _eventService.LogProviderUsersEventAsync(events);
return result;
}
public async Task SaveUserAsync(ProviderUser user, Guid savingUserId)
{
if (user.Id.Equals(default))
{
throw new BadRequestException("Invite the user first.");
}
if (user.Type != ProviderUserType.ProviderAdmin &&
!await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] { user.Id }))
{
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
}
await _providerUserRepository.ReplaceAsync(user);
await _eventService.LogProviderUserEventAsync(user, EventType.ProviderUser_Updated);
}
public async Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId,
IEnumerable<Guid> providerUserIds, Guid deletingUserId)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
throw new NotFoundException();
}
var providerUsers = await _providerUserRepository.GetManyAsync(providerUserIds);
var users = await _userRepository.GetManyAsync(providerUsers.Where(pu => pu.UserId.HasValue)
.Select(pu => pu.UserId.Value));
var keyedUsers = users.ToDictionary(u => u.Id);
if (!await HasConfirmedProviderAdminExceptAsync(providerId, providerUserIds))
{
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
}
var result = new List<Tuple<ProviderUser, string>>();
var deletedUserIds = new List<Guid>();
var events = new List<(ProviderUser, EventType, DateTime?)>();
foreach (var providerUser in providerUsers)
{
try
{
if (providerUser.ProviderId != providerId)
{
throw new BadRequestException("Invalid user.");
}
if (providerUser.UserId == deletingUserId)
{
throw new BadRequestException("You cannot remove yourself.");
}
events.Add((providerUser, EventType.ProviderUser_Removed, null));
var user = keyedUsers.GetValueOrDefault(providerUser.UserId.GetValueOrDefault());
var email = user == null ? providerUser.Email : user.Email;
if (!string.IsNullOrWhiteSpace(email))
{
await _mailService.SendProviderUserRemoved(provider.Name, email);
}
result.Add(Tuple.Create(providerUser, ""));
deletedUserIds.Add(providerUser.Id);
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(providerUser, e.Message));
}
await _providerUserRepository.DeleteManyAsync(deletedUserIds);
}
await _eventService.LogProviderUsersEventAsync(events);
return result;
}
public async Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key)
{
var po = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
if (po != null)
{
throw new BadRequestException("Organization already belongs to a provider.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
ThrowOnInvalidPlanType(organization.PlanType);
var providerOrganization = new ProviderOrganization
{
ProviderId = providerId,
OrganizationId = organizationId,
Key = key,
};
await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
}
public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
{
ThrowOnInvalidPlanType(organizationSignup.Plan);
var (organization, _) = await _organizationService.SignUpAsync(organizationSignup, true);
var providerOrganization = new ProviderOrganization
{
ProviderId = providerId,
OrganizationId = organization.Id,
Key = organizationSignup.OwnerKey,
};
await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
new (OrganizationUserInvite, string)[]
{
(
new OrganizationUserInvite
{
Emails = new[] { clientOwnerEmail },
AccessAll = true,
Type = OrganizationUserType.Owner,
Permissions = null,
Collections = Array.Empty<SelectionReadOnly>(),
},
null
)
});
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);
var owner = await _userRepository.GetByIdAsync(ownerId);
if (owner == null)
{
throw new BadRequestException("Invalid owner.");
}
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
private async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail)
{
var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {ownerEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await _mailService.SendProviderSetupInviteEmailAsync(provider, token, ownerEmail);
}
public async Task LogProviderAccessToOrganizationAsync(Guid organizationId)
{
if (organizationId == default)
{
return;
}
var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (providerOrganization != null)
{
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_VaultAccessed);
}
if (organization != null)
{
await _eventService.LogOrganizationEventAsync(organization, EventType.Organization_VaultAccessed);
}
}
private async Task SendInviteAsync(ProviderUser providerUser, Provider provider)
{
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
var token = _dataProtector.Protect(
$"ProviderUserInvite {providerUser.Id} {providerUser.Email} {nowMillis}");
await _mailService.SendProviderInviteEmailAsync(provider.Name, providerUser, token, providerUser.Email);
}
private async Task<bool> HasConfirmedProviderAdminExceptAsync(Guid providerId, IEnumerable<Guid> providerUserIds)
{
var providerAdmins = await _providerUserRepository.GetManyByProviderAsync(providerId,
ProviderUserType.ProviderAdmin);
var confirmedOwners = providerAdmins.Where(o => o.Status == ProviderUserStatusType.Confirmed);
var confirmedOwnersIds = confirmedOwners.Select(u => u.Id);
return confirmedOwnersIds.Except(providerUserIds).Any();
}
private void ThrowOnInvalidPlanType(PlanType requestedType)
{
if (ProviderDisllowedOrganizationTypes.Contains(requestedType))
{
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
}
}
}
}

View File

@ -1,14 +0,0 @@
using Bit.CommCore.Services;
using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.CommCore.Utilities
{
public static class ServiceCollectionExtensions
{
public static void AddCommCoreServices(this IServiceCollection services)
{
services.AddScoped<IProviderService, ProviderService>();
}
}
}

View File

@ -1,9 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Core\Core.csproj" /> <ProjectReference Include="..\..\..\src\Core\Core.csproj" />
</ItemGroup> </ItemGroup>

View File

@ -0,0 +1,45 @@
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
public class CreateAccessPoliciesCommand : ICreateAccessPoliciesCommand
{
private readonly IAccessPolicyRepository _accessPolicyRepository;
public CreateAccessPoliciesCommand(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}
public async Task<List<BaseAccessPolicy>> CreateAsync(List<BaseAccessPolicy> accessPolicies)
{
var distinctAccessPolicies = accessPolicies.DistinctBy(baseAccessPolicy =>
{
return baseAccessPolicy switch
{
UserProjectAccessPolicy ap => new Tuple<Guid?, Guid?>(ap.OrganizationUserId, ap.GrantedProjectId),
GroupProjectAccessPolicy ap => new Tuple<Guid?, Guid?>(ap.GroupId, ap.GrantedProjectId),
ServiceAccountProjectAccessPolicy ap => new Tuple<Guid?, Guid?>(ap.ServiceAccountId, ap.GrantedProjectId),
_ => throw new ArgumentException("Unsupported access policy type provided.", nameof(baseAccessPolicy))
};
}).ToList();
if (accessPolicies.Count != distinctAccessPolicies.Count)
{
throw new BadRequestException("Resources must be unique");
}
foreach (var accessPolicy in accessPolicies)
{
if (await _accessPolicyRepository.AccessPolicyExists(accessPolicy))
{
throw new BadRequestException("Resource already exists");
}
}
return await _accessPolicyRepository.CreateManyAsync(accessPolicies);
}
}

View File

@ -0,0 +1,27 @@
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
public class DeleteAccessPolicyCommand : IDeleteAccessPolicyCommand
{
private readonly IAccessPolicyRepository _accessPolicyRepository;
public DeleteAccessPolicyCommand(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}
public async Task DeleteAsync(Guid id)
{
var accessPolicy = await _accessPolicyRepository.GetByIdAsync(id);
if (accessPolicy == null)
{
throw new NotFoundException();
}
await _accessPolicyRepository.DeleteAsync(id);
}
}

View File

@ -0,0 +1,32 @@
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
public class UpdateAccessPolicyCommand : IUpdateAccessPolicyCommand
{
private readonly IAccessPolicyRepository _accessPolicyRepository;
public UpdateAccessPolicyCommand(IAccessPolicyRepository accessPolicyRepository)
{
_accessPolicyRepository = accessPolicyRepository;
}
public async Task<BaseAccessPolicy> UpdateAsync(Guid id, bool read, bool write)
{
var accessPolicy = await _accessPolicyRepository.GetByIdAsync(id);
if (accessPolicy == null)
{
throw new NotFoundException();
}
accessPolicy.Read = read;
accessPolicy.Write = write;
accessPolicy.RevisionDate = DateTime.UtcNow;
await _accessPolicyRepository.ReplaceAsync(accessPolicy);
return accessPolicy;
}
}

View File

@ -0,0 +1,55 @@
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
using Bit.Core.Utilities;
namespace Bit.Commercial.Core.SecretsManager.Commands.AccessTokens;
public class CreateAccessTokenCommand : ICreateAccessTokenCommand
{
private readonly IApiKeyRepository _apiKeyRepository;
private readonly int _clientSecretMaxLength = 30;
private readonly ICurrentContext _currentContext;
private readonly IServiceAccountRepository _serviceAccountRepository;
public CreateAccessTokenCommand(
IApiKeyRepository apiKeyRepository,
ICurrentContext currentContext,
IServiceAccountRepository serviceAccountRepository)
{
_apiKeyRepository = apiKeyRepository;
_currentContext = currentContext;
_serviceAccountRepository = serviceAccountRepository;
}
public async Task<ApiKey> CreateAsync(ApiKey apiKey, Guid userId)
{
if (apiKey.ServiceAccountId == null)
{
throw new BadRequestException();
}
var serviceAccount = await _serviceAccountRepository.GetByIdAsync(apiKey.ServiceAccountId.Value);
var orgAdmin = await _currentContext.OrganizationAdmin(serviceAccount.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _serviceAccountRepository.UserHasWriteAccessToServiceAccount(
apiKey.ServiceAccountId.Value, userId),
_ => false,
};
if (!hasAccess)
{
throw new UnauthorizedAccessException();
}
apiKey.ClientSecret = CoreHelpers.SecureRandomString(_clientSecretMaxLength);
return await _apiKeyRepository.CreateAsync(apiKey);
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Projects;
public class CreateProjectCommand : ICreateProjectCommand
{
private readonly IProjectRepository _projectRepository;
public CreateProjectCommand(IProjectRepository projectRepository)
{
_projectRepository = projectRepository;
}
public async Task<Project> CreateAsync(Project project)
{
return await _projectRepository.CreateAsync(project);
}
}

View File

@ -0,0 +1,75 @@
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Projects;
public class DeleteProjectCommand : IDeleteProjectCommand
{
private readonly IProjectRepository _projectRepository;
private readonly ICurrentContext _currentContext;
public DeleteProjectCommand(IProjectRepository projectRepository, ICurrentContext currentContext)
{
_projectRepository = projectRepository;
_currentContext = currentContext;
}
public async Task<List<Tuple<Project, string>>> DeleteProjects(List<Guid> ids, Guid userId)
{
if (ids.Any() != true || userId == new Guid())
{
throw new ArgumentNullException();
}
var projects = (await _projectRepository.GetManyByIds(ids))?.ToList();
if (projects?.Any() != true || projects.Count != ids.Count)
{
throw new NotFoundException();
}
// Ensure all projects belongs to the same organization
var organizationId = projects.First().OrganizationId;
if (projects.Any(p => p.OrganizationId != organizationId))
{
throw new UnauthorizedAccessException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(organizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var results = new List<Tuple<Project, String>>(projects.Count);
var deleteIds = new List<Guid>();
foreach (var project in projects)
{
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _projectRepository.UserHasWriteAccessToProject(project.Id, userId),
_ => false,
};
if (!hasAccess)
{
results.Add(new Tuple<Project, string>(project, "access denied"));
}
else
{
results.Add(new Tuple<Project, string>(project, ""));
deleteIds.Add(project.Id);
}
}
if (deleteIds.Count > 0)
{
await _projectRepository.DeleteManyByIdAsync(deleteIds);
}
return results;
}
}

View File

@ -0,0 +1,50 @@
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Projects;
public class UpdateProjectCommand : IUpdateProjectCommand
{
private readonly IProjectRepository _projectRepository;
private readonly ICurrentContext _currentContext;
public UpdateProjectCommand(IProjectRepository projectRepository, ICurrentContext currentContext)
{
_projectRepository = projectRepository;
_currentContext = currentContext;
}
public async Task<Project> UpdateAsync(Project updatedProject, Guid userId)
{
var project = await _projectRepository.GetByIdAsync(updatedProject.Id);
if (project == null)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(project.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _projectRepository.UserHasWriteAccessToProject(updatedProject.Id, userId),
_ => false,
};
if (!hasAccess)
{
throw new UnauthorizedAccessException();
}
project.Name = updatedProject.Name;
project.RevisionDate = DateTime.UtcNow;
await _projectRepository.ReplaceAsync(project);
return project;
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
public class CreateSecretCommand : ICreateSecretCommand
{
private readonly ISecretRepository _secretRepository;
public CreateSecretCommand(ISecretRepository secretRepository)
{
_secretRepository = secretRepository;
}
public async Task<Secret> CreateAsync(Secret secret)
{
return await _secretRepository.CreateAsync(secret);
}
}

View File

@ -0,0 +1,44 @@
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
public class DeleteSecretCommand : IDeleteSecretCommand
{
private readonly ISecretRepository _secretRepository;
public DeleteSecretCommand(ISecretRepository secretRepository)
{
_secretRepository = secretRepository;
}
public async Task<List<Tuple<Secret, string>>> DeleteSecrets(List<Guid> ids)
{
var secrets = await _secretRepository.GetManyByIds(ids);
if (secrets?.Any() != true)
{
throw new NotFoundException();
}
var results = ids.Select(id =>
{
var secret = secrets.FirstOrDefault(secret => secret.Id == id);
if (secret == null)
{
throw new NotFoundException();
}
// TODO Once permissions are implemented add check for each secret here.
else
{
return new Tuple<Secret, string>(secret, "");
}
}).ToList();
await _secretRepository.SoftDeleteManyByIdAsync(ids);
return results;
}
}

View File

@ -0,0 +1,33 @@
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.Secrets;
public class UpdateSecretCommand : IUpdateSecretCommand
{
private readonly ISecretRepository _secretRepository;
public UpdateSecretCommand(ISecretRepository secretRepository)
{
_secretRepository = secretRepository;
}
public async Task<Secret> UpdateAsync(Secret secret)
{
var existingSecret = await _secretRepository.GetByIdAsync(secret.Id);
if (existingSecret == null)
{
throw new NotFoundException();
}
secret.OrganizationId = existingSecret.OrganizationId;
secret.CreationDate = existingSecret.CreationDate;
secret.DeletedDate = existingSecret.DeletedDate;
secret.RevisionDate = DateTime.UtcNow;
await _secretRepository.UpdateAsync(secret);
return secret;
}
}

View File

@ -0,0 +1,20 @@
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
public class CreateServiceAccountCommand : ICreateServiceAccountCommand
{
private readonly IServiceAccountRepository _serviceAccountRepository;
public CreateServiceAccountCommand(IServiceAccountRepository serviceAccountRepository)
{
_serviceAccountRepository = serviceAccountRepository;
}
public async Task<ServiceAccount> CreateAsync(ServiceAccount serviceAccount)
{
return await _serviceAccountRepository.CreateAsync(serviceAccount);
}
}

View File

@ -0,0 +1,50 @@
using Bit.Core.Context;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Bit.Core.SecretsManager.Entities;
using Bit.Core.SecretsManager.Repositories;
namespace Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
public class UpdateServiceAccountCommand : IUpdateServiceAccountCommand
{
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly ICurrentContext _currentContext;
public UpdateServiceAccountCommand(IServiceAccountRepository serviceAccountRepository, ICurrentContext currentContext)
{
_serviceAccountRepository = serviceAccountRepository;
_currentContext = currentContext;
}
public async Task<ServiceAccount> UpdateAsync(ServiceAccount updatedServiceAccount, Guid userId)
{
var serviceAccount = await _serviceAccountRepository.GetByIdAsync(updatedServiceAccount.Id);
if (serviceAccount == null)
{
throw new NotFoundException();
}
var orgAdmin = await _currentContext.OrganizationAdmin(serviceAccount.OrganizationId);
var accessClient = AccessClientHelper.ToAccessClient(_currentContext.ClientType, orgAdmin);
var hasAccess = accessClient switch
{
AccessClientType.NoAccessCheck => true,
AccessClientType.User => await _serviceAccountRepository.UserHasWriteAccessToServiceAccount(updatedServiceAccount.Id, userId),
_ => false,
};
if (!hasAccess)
{
throw new UnauthorizedAccessException();
}
serviceAccount.Name = updatedServiceAccount.Name;
serviceAccount.RevisionDate = DateTime.UtcNow;
await _serviceAccountRepository.ReplaceAsync(serviceAccount);
return serviceAccount;
}
}

View File

@ -0,0 +1,32 @@
using Bit.Commercial.Core.SecretsManager.Commands.AccessPolicies;
using Bit.Commercial.Core.SecretsManager.Commands.AccessTokens;
using Bit.Commercial.Core.SecretsManager.Commands.Projects;
using Bit.Commercial.Core.SecretsManager.Commands.Secrets;
using Bit.Commercial.Core.SecretsManager.Commands.ServiceAccounts;
using Bit.Core.SecretsManager.Commands.AccessPolicies.Interfaces;
using Bit.Core.SecretsManager.Commands.AccessTokens.Interfaces;
using Bit.Core.SecretsManager.Commands.Projects.Interfaces;
using Bit.Core.SecretsManager.Commands.Secrets.Interfaces;
using Bit.Core.SecretsManager.Commands.ServiceAccounts.Interfaces;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.SecretsManager;
public static class SecretsManagerCollectionExtensions
{
public static void AddSecretsManagerServices(this IServiceCollection services)
{
services.AddScoped<ICreateSecretCommand, CreateSecretCommand>();
services.AddScoped<IUpdateSecretCommand, UpdateSecretCommand>();
services.AddScoped<IDeleteSecretCommand, DeleteSecretCommand>();
services.AddScoped<ICreateProjectCommand, CreateProjectCommand>();
services.AddScoped<IUpdateProjectCommand, UpdateProjectCommand>();
services.AddScoped<IDeleteProjectCommand, DeleteProjectCommand>();
services.AddScoped<ICreateServiceAccountCommand, CreateServiceAccountCommand>();
services.AddScoped<IUpdateServiceAccountCommand, UpdateServiceAccountCommand>();
services.AddScoped<ICreateAccessTokenCommand, CreateAccessTokenCommand>();
services.AddScoped<ICreateAccessPoliciesCommand, CreateAccessPoliciesCommand>();
services.AddScoped<IUpdateAccessPolicyCommand, UpdateAccessPolicyCommand>();
services.AddScoped<IDeleteAccessPolicyCommand, DeleteAccessPolicyCommand>();
}
}

View File

@ -0,0 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.SecretsManager;
public static class SecretsManagerServiceCollectionExtensions
{
public static void AddCommercialSecretsManagerServices(this IServiceCollection services)
{
services.AddSecretsManagerServices();
}
}

View File

@ -0,0 +1,508 @@
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Entities.Provider;
using Bit.Core.Enums;
using Bit.Core.Enums.Provider;
using Bit.Core.Exceptions;
using Bit.Core.Models.Business;
using Bit.Core.Models.Business.Provider;
using Bit.Core.Models.Data;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.DataProtection;
namespace Bit.Commercial.Core.Services;
public class ProviderService : IProviderService
{
public static PlanType[] ProviderDisllowedOrganizationTypes = new[] { PlanType.Free, PlanType.FamiliesAnnually, PlanType.FamiliesAnnually2019 };
private readonly IDataProtector _dataProtector;
private readonly IMailService _mailService;
private readonly IEventService _eventService;
private readonly GlobalSettings _globalSettings;
private readonly IProviderRepository _providerRepository;
private readonly IProviderUserRepository _providerUserRepository;
private readonly IProviderOrganizationRepository _providerOrganizationRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserRepository _userRepository;
private readonly IUserService _userService;
private readonly IOrganizationService _organizationService;
private readonly ICurrentContext _currentContext;
public ProviderService(IProviderRepository providerRepository, IProviderUserRepository providerUserRepository,
IProviderOrganizationRepository providerOrganizationRepository, IUserRepository userRepository,
IUserService userService, IOrganizationService organizationService, IMailService mailService,
IDataProtectionProvider dataProtectionProvider, IEventService eventService,
IOrganizationRepository organizationRepository, GlobalSettings globalSettings,
ICurrentContext currentContext)
{
_providerRepository = providerRepository;
_providerUserRepository = providerUserRepository;
_providerOrganizationRepository = providerOrganizationRepository;
_organizationRepository = organizationRepository;
_userRepository = userRepository;
_userService = userService;
_organizationService = organizationService;
_mailService = mailService;
_eventService = eventService;
_globalSettings = globalSettings;
_dataProtector = dataProtectionProvider.CreateProtector("ProviderServiceDataProtector");
_currentContext = currentContext;
}
public async Task CreateAsync(string ownerEmail)
{
var owner = await _userRepository.GetByEmailAsync(ownerEmail);
if (owner == null)
{
throw new BadRequestException("Invalid owner. Owner must be an existing Bitwarden user.");
}
var provider = new Provider
{
Status = ProviderStatusType.Pending,
Enabled = true,
UseEvents = true,
};
await _providerRepository.CreateAsync(provider);
var providerUser = new ProviderUser
{
ProviderId = provider.Id,
UserId = owner.Id,
Type = ProviderUserType.ProviderAdmin,
Status = ProviderUserStatusType.Confirmed,
};
await _providerUserRepository.CreateAsync(providerUser);
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
public async Task<Provider> CompleteSetupAsync(Provider provider, Guid ownerUserId, string token, string key)
{
var owner = await _userService.GetUserByIdAsync(ownerUserId);
if (owner == null)
{
throw new BadRequestException("Invalid owner.");
}
if (provider.Status != ProviderStatusType.Pending)
{
throw new BadRequestException("Provider is already setup.");
}
if (!CoreHelpers.TokenIsValid("ProviderSetupInvite", _dataProtector, token, owner.Email, provider.Id,
_globalSettings.OrganizationInviteExpirationHours))
{
throw new BadRequestException("Invalid token.");
}
var providerUser = await _providerUserRepository.GetByProviderUserAsync(provider.Id, ownerUserId);
if (!(providerUser is { Type: ProviderUserType.ProviderAdmin }))
{
throw new BadRequestException("Invalid owner.");
}
provider.Status = ProviderStatusType.Created;
await _providerRepository.UpsertAsync(provider);
providerUser.Key = key;
await _providerUserRepository.ReplaceAsync(providerUser);
return provider;
}
public async Task UpdateAsync(Provider provider, bool updateBilling = false)
{
if (provider.Id == default)
{
throw new ArgumentException("Cannot create provider this way.");
}
await _providerRepository.ReplaceAsync(provider);
}
public async Task<List<ProviderUser>> InviteUserAsync(ProviderUserInvite<string> invite)
{
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
{
throw new InvalidOperationException("Invalid permissions.");
}
var emails = invite?.UserIdentifiers;
var invitingUser = await _providerUserRepository.GetByProviderUserAsync(invite.ProviderId, invite.InvitingUserId);
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
if (provider == null || emails == null || !emails.Any())
{
throw new NotFoundException();
}
var providerUsers = new List<ProviderUser>();
foreach (var email in emails)
{
// Make sure user is not already invited
var existingProviderUserCount =
await _providerUserRepository.GetCountByProviderAsync(invite.ProviderId, email, false);
if (existingProviderUserCount > 0)
{
continue;
}
var providerUser = new ProviderUser
{
ProviderId = invite.ProviderId,
UserId = null,
Email = email.ToLowerInvariant(),
Key = null,
Type = invite.Type,
Status = ProviderUserStatusType.Invited,
CreationDate = DateTime.UtcNow,
RevisionDate = DateTime.UtcNow,
};
await _providerUserRepository.CreateAsync(providerUser);
await SendInviteAsync(providerUser, provider);
providerUsers.Add(providerUser);
}
await _eventService.LogProviderUsersEventAsync(providerUsers.Select(pu => (pu, EventType.ProviderUser_Invited, null as DateTime?)));
return providerUsers;
}
public async Task<List<Tuple<ProviderUser, string>>> ResendInvitesAsync(ProviderUserInvite<Guid> invite)
{
if (!_currentContext.ProviderManageUsers(invite.ProviderId))
{
throw new BadRequestException("Invalid permissions.");
}
var providerUsers = await _providerUserRepository.GetManyAsync(invite.UserIdentifiers);
var provider = await _providerRepository.GetByIdAsync(invite.ProviderId);
var result = new List<Tuple<ProviderUser, string>>();
foreach (var providerUser in providerUsers)
{
if (providerUser.Status != ProviderUserStatusType.Invited || providerUser.ProviderId != invite.ProviderId)
{
result.Add(Tuple.Create(providerUser, "User invalid."));
continue;
}
await SendInviteAsync(providerUser, provider);
result.Add(Tuple.Create(providerUser, ""));
}
return result;
}
public async Task<ProviderUser> AcceptUserAsync(Guid providerUserId, User user, string token)
{
var providerUser = await _providerUserRepository.GetByIdAsync(providerUserId);
if (providerUser == null)
{
throw new BadRequestException("User invalid.");
}
if (providerUser.Status != ProviderUserStatusType.Invited)
{
throw new BadRequestException("Already accepted.");
}
if (!CoreHelpers.TokenIsValid("ProviderUserInvite", _dataProtector, token, user.Email, providerUser.Id,
_globalSettings.OrganizationInviteExpirationHours))
{
throw new BadRequestException("Invalid token.");
}
if (string.IsNullOrWhiteSpace(providerUser.Email) ||
!providerUser.Email.Equals(user.Email, StringComparison.InvariantCultureIgnoreCase))
{
throw new BadRequestException("User email does not match invite.");
}
providerUser.Status = ProviderUserStatusType.Accepted;
providerUser.UserId = user.Id;
providerUser.Email = null;
await _providerUserRepository.ReplaceAsync(providerUser);
return providerUser;
}
public async Task<List<Tuple<ProviderUser, string>>> ConfirmUsersAsync(Guid providerId, Dictionary<Guid, string> keys,
Guid confirmingUserId)
{
var providerUsers = await _providerUserRepository.GetManyAsync(keys.Keys);
var validProviderUsers = providerUsers
.Where(u => u.UserId != null)
.ToList();
if (!validProviderUsers.Any())
{
return new List<Tuple<ProviderUser, string>>();
}
var validOrganizationUserIds = validProviderUsers.Select(u => u.UserId.Value).ToList();
var provider = await _providerRepository.GetByIdAsync(providerId);
var users = await _userRepository.GetManyAsync(validOrganizationUserIds);
var keyedFilteredUsers = validProviderUsers.ToDictionary(u => u.UserId.Value, u => u);
var result = new List<Tuple<ProviderUser, string>>();
var events = new List<(ProviderUser, EventType, DateTime?)>();
foreach (var user in users)
{
if (!keyedFilteredUsers.ContainsKey(user.Id))
{
continue;
}
var providerUser = keyedFilteredUsers[user.Id];
try
{
if (providerUser.Status != ProviderUserStatusType.Accepted || providerUser.ProviderId != providerId)
{
throw new BadRequestException("Invalid user.");
}
providerUser.Status = ProviderUserStatusType.Confirmed;
providerUser.Key = keys[providerUser.Id];
providerUser.Email = null;
await _providerUserRepository.ReplaceAsync(providerUser);
events.Add((providerUser, EventType.ProviderUser_Confirmed, null));
await _mailService.SendProviderConfirmedEmailAsync(provider.Name, user.Email);
result.Add(Tuple.Create(providerUser, ""));
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(providerUser, e.Message));
}
}
await _eventService.LogProviderUsersEventAsync(events);
return result;
}
public async Task SaveUserAsync(ProviderUser user, Guid savingUserId)
{
if (user.Id.Equals(default))
{
throw new BadRequestException("Invite the user first.");
}
if (user.Type != ProviderUserType.ProviderAdmin &&
!await HasConfirmedProviderAdminExceptAsync(user.ProviderId, new[] { user.Id }))
{
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
}
await _providerUserRepository.ReplaceAsync(user);
await _eventService.LogProviderUserEventAsync(user, EventType.ProviderUser_Updated);
}
public async Task<List<Tuple<ProviderUser, string>>> DeleteUsersAsync(Guid providerId,
IEnumerable<Guid> providerUserIds, Guid deletingUserId)
{
var provider = await _providerRepository.GetByIdAsync(providerId);
if (provider == null)
{
throw new NotFoundException();
}
var providerUsers = await _providerUserRepository.GetManyAsync(providerUserIds);
var users = await _userRepository.GetManyAsync(providerUsers.Where(pu => pu.UserId.HasValue)
.Select(pu => pu.UserId.Value));
var keyedUsers = users.ToDictionary(u => u.Id);
if (!await HasConfirmedProviderAdminExceptAsync(providerId, providerUserIds))
{
throw new BadRequestException("Provider must have at least one confirmed ProviderAdmin.");
}
var result = new List<Tuple<ProviderUser, string>>();
var deletedUserIds = new List<Guid>();
var events = new List<(ProviderUser, EventType, DateTime?)>();
foreach (var providerUser in providerUsers)
{
try
{
if (providerUser.ProviderId != providerId)
{
throw new BadRequestException("Invalid user.");
}
if (providerUser.UserId == deletingUserId)
{
throw new BadRequestException("You cannot remove yourself.");
}
events.Add((providerUser, EventType.ProviderUser_Removed, null));
var user = keyedUsers.GetValueOrDefault(providerUser.UserId.GetValueOrDefault());
var email = user == null ? providerUser.Email : user.Email;
if (!string.IsNullOrWhiteSpace(email))
{
await _mailService.SendProviderUserRemoved(provider.Name, email);
}
result.Add(Tuple.Create(providerUser, ""));
deletedUserIds.Add(providerUser.Id);
}
catch (BadRequestException e)
{
result.Add(Tuple.Create(providerUser, e.Message));
}
await _providerUserRepository.DeleteManyAsync(deletedUserIds);
}
await _eventService.LogProviderUsersEventAsync(events);
return result;
}
public async Task AddOrganization(Guid providerId, Guid organizationId, Guid addingUserId, string key)
{
var po = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
if (po != null)
{
throw new BadRequestException("Organization already belongs to a provider.");
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
ThrowOnInvalidPlanType(organization.PlanType);
var providerOrganization = new ProviderOrganization
{
ProviderId = providerId,
OrganizationId = organizationId,
Key = key,
};
await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Added);
}
public async Task<ProviderOrganization> CreateOrganizationAsync(Guid providerId,
OrganizationSignup organizationSignup, string clientOwnerEmail, User user)
{
ThrowOnInvalidPlanType(organizationSignup.Plan);
var (organization, _) = await _organizationService.SignUpAsync(organizationSignup, true);
var providerOrganization = new ProviderOrganization
{
ProviderId = providerId,
OrganizationId = organization.Id,
Key = organizationSignup.OwnerKey,
};
await _providerOrganizationRepository.CreateAsync(providerOrganization);
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_Created);
await _organizationService.InviteUsersAsync(organization.Id, user.Id,
new (OrganizationUserInvite, string)[]
{
(
new OrganizationUserInvite
{
Emails = new[] { clientOwnerEmail },
AccessAll = true,
Type = OrganizationUserType.Owner,
Permissions = null,
Collections = Array.Empty<CollectionAccessSelection>(),
},
null
)
});
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);
var owner = await _userRepository.GetByIdAsync(ownerId);
if (owner == null)
{
throw new BadRequestException("Invalid owner.");
}
await SendProviderSetupInviteEmailAsync(provider, owner.Email);
}
private async Task SendProviderSetupInviteEmailAsync(Provider provider, string ownerEmail)
{
var token = _dataProtector.Protect($"ProviderSetupInvite {provider.Id} {ownerEmail} {CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow)}");
await _mailService.SendProviderSetupInviteEmailAsync(provider, token, ownerEmail);
}
public async Task LogProviderAccessToOrganizationAsync(Guid organizationId)
{
if (organizationId == default)
{
return;
}
var providerOrganization = await _providerOrganizationRepository.GetByOrganizationId(organizationId);
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (providerOrganization != null)
{
await _eventService.LogProviderOrganizationEventAsync(providerOrganization, EventType.ProviderOrganization_VaultAccessed);
}
if (organization != null)
{
await _eventService.LogOrganizationEventAsync(organization, EventType.Organization_VaultAccessed);
}
}
private async Task SendInviteAsync(ProviderUser providerUser, Provider provider)
{
var nowMillis = CoreHelpers.ToEpocMilliseconds(DateTime.UtcNow);
var token = _dataProtector.Protect(
$"ProviderUserInvite {providerUser.Id} {providerUser.Email} {nowMillis}");
await _mailService.SendProviderInviteEmailAsync(provider.Name, providerUser, token, providerUser.Email);
}
private async Task<bool> HasConfirmedProviderAdminExceptAsync(Guid providerId, IEnumerable<Guid> providerUserIds)
{
var providerAdmins = await _providerUserRepository.GetManyByProviderAsync(providerId,
ProviderUserType.ProviderAdmin);
var confirmedOwners = providerAdmins.Where(o => o.Status == ProviderUserStatusType.Confirmed);
var confirmedOwnersIds = confirmedOwners.Select(u => u.Id);
return confirmedOwnersIds.Except(providerUserIds).Any();
}
private void ThrowOnInvalidPlanType(PlanType requestedType)
{
if (ProviderDisllowedOrganizationTypes.Contains(requestedType))
{
throw new BadRequestException($"Providers cannot manage organizations with the requested plan type ({requestedType}). Only Teams and Enterprise accounts are allowed.");
}
}
}

View File

@ -0,0 +1,13 @@
using Bit.Commercial.Core.Services;
using Bit.Core.Services;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Core.Utilities;
public static class ServiceCollectionExtensions
{
public static void AddCommercialCoreServices(this IServiceCollection services)
{
services.AddScoped<IProviderService, ProviderService>();
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="11.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\..\..\src\Infrastructure.EntityFramework\Infrastructure.EntityFramework.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,184 @@
using AutoMapper;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class AccessPolicyRepository : BaseEntityFrameworkRepository, IAccessPolicyRepository
{
public AccessPolicyRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper) : base(serviceScopeFactory,
mapper)
{
}
public async Task<List<Core.SecretsManager.Entities.BaseAccessPolicy>> CreateManyAsync(List<Core.SecretsManager.Entities.BaseAccessPolicy> baseAccessPolicies)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
foreach (var baseAccessPolicy in baseAccessPolicies)
{
baseAccessPolicy.SetNewId();
switch (baseAccessPolicy)
{
case Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy:
{
var entity =
Mapper.Map<UserProjectAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.UserServiceAccountAccessPolicy accessPolicy:
{
var entity =
Mapper.Map<UserServiceAccountAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupProjectAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.GroupServiceAccountAccessPolicy accessPolicy:
{
var entity = Mapper.Map<GroupServiceAccountAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
case Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy:
{
var entity = Mapper.Map<ServiceAccountProjectAccessPolicy>(accessPolicy);
await dbContext.AddAsync(entity);
break;
}
}
}
await dbContext.SaveChangesAsync();
return baseAccessPolicies;
}
}
public async Task<bool> AccessPolicyExists(Core.SecretsManager.Entities.BaseAccessPolicy baseAccessPolicy)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
switch (baseAccessPolicy)
{
case Core.SecretsManager.Entities.UserProjectAccessPolicy accessPolicy:
{
var policy = await dbContext.UserProjectAccessPolicy
.Where(c => c.OrganizationUserId == accessPolicy.OrganizationUserId &&
c.GrantedProjectId == accessPolicy.GrantedProjectId)
.FirstOrDefaultAsync();
return policy != null;
}
case Core.SecretsManager.Entities.GroupProjectAccessPolicy accessPolicy:
{
var policy = await dbContext.GroupProjectAccessPolicy
.Where(c => c.GroupId == accessPolicy.GroupId &&
c.GrantedProjectId == accessPolicy.GrantedProjectId)
.FirstOrDefaultAsync();
return policy != null;
}
case Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy accessPolicy:
{
var policy = await dbContext.ServiceAccountProjectAccessPolicy
.Where(c => c.ServiceAccountId == accessPolicy.ServiceAccountId &&
c.GrantedProjectId == accessPolicy.GrantedProjectId)
.FirstOrDefaultAsync();
return policy != null;
}
default:
throw new ArgumentException("Unsupported access policy type provided.", nameof(baseAccessPolicy));
}
}
}
public async Task<Core.SecretsManager.Entities.BaseAccessPolicy?> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entity = await dbContext.AccessPolicies.Where(ap => ap.Id == id)
.Include(ap => ((UserProjectAccessPolicy)ap).OrganizationUser.User)
.Include(ap => ((GroupProjectAccessPolicy)ap).Group)
.Include(ap => ((ServiceAccountProjectAccessPolicy)ap).ServiceAccount)
.FirstOrDefaultAsync();
if (entity == null)
{
return null;
}
return MapToCore(entity);
}
}
public async Task ReplaceAsync(Core.SecretsManager.Entities.BaseAccessPolicy baseAccessPolicy)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entity = await dbContext.AccessPolicies.FindAsync(baseAccessPolicy.Id);
if (entity != null)
{
dbContext.AccessPolicies.Attach(entity);
entity.Write = baseAccessPolicy.Write;
entity.Read = baseAccessPolicy.Read;
entity.RevisionDate = baseAccessPolicy.RevisionDate;
await dbContext.SaveChangesAsync();
}
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.BaseAccessPolicy>?> GetManyByProjectId(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entities = await dbContext.AccessPolicies.Where(ap =>
((UserProjectAccessPolicy)ap).GrantedProjectId == id ||
((GroupProjectAccessPolicy)ap).GrantedProjectId == id ||
((ServiceAccountProjectAccessPolicy)ap).GrantedProjectId == id)
.Include(ap => ((UserProjectAccessPolicy)ap).OrganizationUser.User)
.Include(ap => ((GroupProjectAccessPolicy)ap).Group)
.Include(ap => ((ServiceAccountProjectAccessPolicy)ap).ServiceAccount)
.ToListAsync();
return !entities.Any() ? null : entities.Select(MapToCore);
}
}
public async Task DeleteAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var entity = await dbContext.AccessPolicies.FindAsync(id);
if (entity != null)
{
dbContext.Remove(entity);
await dbContext.SaveChangesAsync();
}
}
}
private Core.SecretsManager.Entities.BaseAccessPolicy MapToCore(BaseAccessPolicy baseAccessPolicyEntity)
{
return baseAccessPolicyEntity switch
{
UserProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.UserProjectAccessPolicy>(ap),
GroupProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.GroupProjectAccessPolicy>(ap),
ServiceAccountProjectAccessPolicy ap => Mapper.Map<Core.SecretsManager.Entities.ServiceAccountProjectAccessPolicy>(ap),
_ => throw new ArgumentException("Unsupported access policy type")
};
}
}

View File

@ -0,0 +1,106 @@
using System.Linq.Expressions;
using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class ProjectRepository : Repository<Core.SecretsManager.Entities.Project, Project, Guid>, IProjectRepository
{
public ProjectRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, db => db.Project)
{ }
public override async Task<Core.SecretsManager.Entities.Project> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var project = await dbContext.Project
.Where(c => c.Id == id && c.DeletedDate == null)
.FirstOrDefaultAsync();
return Mapper.Map<Core.SecretsManager.Entities.Project>(project);
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Project>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project.Where(p => p.OrganizationId == organizationId && p.DeletedDate == null);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToProject(userId)),
AccessClientType.ServiceAccount => query.Where(ServiceAccountHasReadAccessToProject(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var projects = await query.OrderBy(p => p.RevisionDate).ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Project>>(projects);
}
private static Expression<Func<Project, bool>> UserHasReadAccessToProject(Guid userId) => p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
p.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read));
private static Expression<Func<Project, bool>> UserHasWriteAccessToProject(Guid userId) => p =>
p.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
p.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write));
private static Expression<Func<Project, bool>> ServiceAccountHasReadAccessToProject(Guid serviceAccountId) => p =>
p.ServiceAccountAccessPolicies.Any(ap => ap.ServiceAccount.Id == serviceAccountId && ap.Read);
public async Task DeleteManyByIdAsync(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var projects = dbContext.Project.Where(c => ids.Contains(c.Id));
await projects.ForEachAsync(project =>
{
dbContext.Remove(project);
});
await dbContext.SaveChangesAsync();
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Project>> GetManyByIds(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var projects = await dbContext.Project
.Where(c => ids.Contains(c.Id) && c.DeletedDate == null)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Project>>(projects);
}
}
public async Task<bool> UserHasReadAccessToProject(Guid id, Guid userId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project
.Where(p => p.Id == id)
.Where(UserHasReadAccessToProject(userId));
return await query.AnyAsync();
}
public async Task<bool> UserHasWriteAccessToProject(Guid id, Guid userId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.Project
.Where(p => p.Id == id)
.Where(UserHasWriteAccessToProject(userId));
return await query.AnyAsync();
}
}

View File

@ -0,0 +1,139 @@
using AutoMapper;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class SecretRepository : Repository<Core.SecretsManager.Entities.Secret, Secret, Guid>, ISecretRepository
{
public SecretRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, db => db.Secret)
{ }
public override async Task<Core.SecretsManager.Entities.Secret> GetByIdAsync(Guid id)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var secret = await dbContext.Secret
.Include("Projects")
.Where(c => c.Id == id && c.DeletedDate == null)
.FirstOrDefaultAsync();
return Mapper.Map<Core.SecretsManager.Entities.Secret>(secret);
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByIds(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var secrets = await dbContext.Secret
.Where(c => ids.Contains(c.Id) && c.DeletedDate == null)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByOrganizationIdAsync(Guid organizationId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var secrets = await dbContext.Secret
.Where(c => c.OrganizationId == organizationId && c.DeletedDate == null)
.Include("Projects")
.OrderBy(c => c.RevisionDate)
.ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
}
public async Task<IEnumerable<Core.SecretsManager.Entities.Secret>> GetManyByProjectIdAsync(Guid projectId)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var secrets = await dbContext.Secret
.Where(s => s.Projects.Any(p => p.Id == projectId) && s.DeletedDate == null).Include("Projects")
.OrderBy(s => s.RevisionDate).ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.Secret>>(secrets);
}
}
public override async Task<Core.SecretsManager.Entities.Secret> CreateAsync(Core.SecretsManager.Entities.Secret secret)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
secret.SetNewId();
var entity = Mapper.Map<Secret>(secret);
if (secret.Projects?.Count > 0)
{
foreach (var p in entity.Projects)
{
dbContext.Attach(p);
}
}
await dbContext.AddAsync(entity);
await dbContext.SaveChangesAsync();
secret.Id = entity.Id;
return secret;
}
}
public async Task<Core.SecretsManager.Entities.Secret> UpdateAsync(Core.SecretsManager.Entities.Secret secret)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var mappedEntity = Mapper.Map<Secret>(secret);
var entity = await dbContext.Secret
.Include("Projects")
.FirstAsync(s => s.Id == secret.Id);
foreach (var p in entity.Projects?.Where(p => mappedEntity.Projects.All(mp => mp.Id != p.Id)))
{
entity.Projects.Remove(p);
}
// Add new relationships
foreach (var project in mappedEntity.Projects?.Where(p => entity.Projects.All(ep => ep.Id != p.Id)))
{
var p = dbContext.AttachToOrGet<Project>(_ => _.Id == project.Id, () => project);
entity.Projects.Add(p);
}
dbContext.Entry(entity).CurrentValues.SetValues(mappedEntity);
await dbContext.SaveChangesAsync();
}
return secret;
}
public async Task SoftDeleteManyByIdAsync(IEnumerable<Guid> ids)
{
using (var scope = ServiceScopeFactory.CreateScope())
{
var dbContext = GetDatabaseContext(scope);
var utcNow = DateTime.UtcNow;
var secrets = dbContext.Secret.Where(c => ids.Contains(c.Id));
await secrets.ForEachAsync(secret =>
{
dbContext.Attach(secret);
secret.DeletedDate = utcNow;
secret.RevisionDate = utcNow;
});
await dbContext.SaveChangesAsync();
}
}
}

View File

@ -0,0 +1,64 @@
using System.Linq.Expressions;
using AutoMapper;
using Bit.Core.Enums;
using Bit.Core.SecretsManager.Repositories;
using Bit.Infrastructure.EntityFramework.Repositories;
using Bit.Infrastructure.EntityFramework.SecretsManager.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
public class ServiceAccountRepository : Repository<Core.SecretsManager.Entities.ServiceAccount, ServiceAccount, Guid>, IServiceAccountRepository
{
public ServiceAccountRepository(IServiceScopeFactory serviceScopeFactory, IMapper mapper)
: base(serviceScopeFactory, mapper, db => db.ServiceAccount)
{ }
public async Task<IEnumerable<Core.SecretsManager.Entities.ServiceAccount>> GetManyByOrganizationIdAsync(Guid organizationId, Guid userId, AccessClientType accessType)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.ServiceAccount.Where(c => c.OrganizationId == organizationId);
query = accessType switch
{
AccessClientType.NoAccessCheck => query,
AccessClientType.User => query.Where(UserHasReadAccessToServiceAccount(userId)),
_ => throw new ArgumentOutOfRangeException(nameof(accessType), accessType, null),
};
var serviceAccounts = await query.OrderBy(c => c.RevisionDate).ToListAsync();
return Mapper.Map<List<Core.SecretsManager.Entities.ServiceAccount>>(serviceAccounts);
}
public async Task<bool> UserHasReadAccessToServiceAccount(Guid id, Guid userId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.ServiceAccount
.Where(sa => sa.Id == id)
.Where(UserHasReadAccessToServiceAccount(userId));
return await query.AnyAsync();
}
public async Task<bool> UserHasWriteAccessToServiceAccount(Guid id, Guid userId)
{
using var scope = ServiceScopeFactory.CreateScope();
var dbContext = GetDatabaseContext(scope);
var query = dbContext.ServiceAccount
.Where(sa => sa.Id == id)
.Where(UserHasWriteAccessToServiceAccount(userId));
return await query.AnyAsync();
}
private static Expression<Func<ServiceAccount, bool>> UserHasReadAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Read) ||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Read));
private static Expression<Func<ServiceAccount, bool>> UserHasWriteAccessToServiceAccount(Guid userId) => sa =>
sa.UserAccessPolicies.Any(ap => ap.OrganizationUser.User.Id == userId && ap.Write) ||
sa.GroupAccessPolicies.Any(ap => ap.Group.GroupUsers.Any(gu => gu.OrganizationUser.User.Id == userId && ap.Write));
}

View File

@ -0,0 +1,16 @@
using Bit.Commercial.Infrastructure.EntityFramework.SecretsManager.Repositories;
using Bit.Core.SecretsManager.Repositories;
using Microsoft.Extensions.DependencyInjection;
namespace Bit.Commercial.Infrastructure.EntityFramework.SecretsManager;
public static class SecretsManagerEfServiceCollectionExtensions
{
public static void AddSecretsManagerEfRepositories(this IServiceCollection services)
{
services.AddSingleton<IAccessPolicyRepository, AccessPolicyRepository>();
services.AddSingleton<ISecretRepository, SecretRepository>();
services.AddSingleton<IProjectRepository, ProjectRepository>();
services.AddSingleton<IServiceAccountRepository, ServiceAccountRepository>();
}
}

View File

@ -0,0 +1,4 @@
*
!obj/build-output/publish/*
!obj/Docker/empty/
!entrypoint.sh

View File

@ -0,0 +1,20 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.Repositories;
using Bit.Core.Settings;
namespace Bit.Scim.Context;
public interface IScimContext
{
ScimProviderType RequestScimProvider { get; set; }
ScimConfig ScimConfiguration { get; set; }
Guid? OrganizationId { get; set; }
Organization Organization { get; set; }
Task BuildAsync(
HttpContext httpContext,
GlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IOrganizationConnectionRepository organizationConnectionRepository);
}

View File

@ -0,0 +1,63 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Models.OrganizationConnectionConfigs;
using Bit.Core.Repositories;
using Bit.Core.Settings;
namespace Bit.Scim.Context;
public class ScimContext : IScimContext
{
private bool _builtHttpContext;
public ScimProviderType RequestScimProvider { get; set; } = ScimProviderType.Default;
public ScimConfig ScimConfiguration { get; set; }
public Guid? OrganizationId { get; set; }
public Organization Organization { get; set; }
public async virtual Task BuildAsync(
HttpContext httpContext,
GlobalSettings globalSettings,
IOrganizationRepository organizationRepository,
IOrganizationConnectionRepository organizationConnectionRepository)
{
if (_builtHttpContext)
{
return;
}
_builtHttpContext = true;
string orgIdString = null;
if (httpContext.Request.RouteValues.TryGetValue("organizationId", out var orgIdObject))
{
orgIdString = orgIdObject?.ToString();
}
if (Guid.TryParse(orgIdString, out var orgId))
{
OrganizationId = orgId;
Organization = await organizationRepository.GetByIdAsync(orgId);
if (Organization != null)
{
var scimConnections = await organizationConnectionRepository.GetByOrganizationIdTypeAsync(Organization.Id,
OrganizationConnectionType.Scim);
ScimConfiguration = scimConnections?.FirstOrDefault()?.GetConfig<ScimConfig>();
}
}
if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.TryGetValue("User-Agent", out var userAgent))
{
if (userAgent.ToString().StartsWith("Okta"))
{
RequestScimProvider = ScimProviderType.Okta;
}
}
if (RequestScimProvider == ScimProviderType.Default &&
httpContext.Request.Headers.ContainsKey("Adscimversion"))
{
RequestScimProvider = ScimProviderType.AzureAd;
}
}
}

View File

@ -0,0 +1,22 @@
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Scim.Controllers;
[AllowAnonymous]
public class InfoController : Controller
{
[HttpGet("~/alive")]
[HttpGet("~/now")]
public DateTime GetAlive()
{
return DateTime.UtcNow;
}
[HttpGet("~/version")]
public JsonResult GetVersion()
{
return Json(AssemblyHelpers.GetVersion());
}
}

View File

@ -0,0 +1,109 @@
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
using Bit.Scim.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Scim.Controllers.v2;
[Authorize("Scim")]
[Route("v2/{organizationId}/groups")]
[ExceptionHandlerFilter]
public class GroupsController : Controller
{
private readonly IGroupRepository _groupRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IGetGroupsListQuery _getGroupsListQuery;
private readonly IDeleteGroupCommand _deleteGroupCommand;
private readonly IPatchGroupCommand _patchGroupCommand;
private readonly IPostGroupCommand _postGroupCommand;
private readonly IPutGroupCommand _putGroupCommand;
private readonly ILogger<GroupsController> _logger;
public GroupsController(
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IGetGroupsListQuery getGroupsListQuery,
IDeleteGroupCommand deleteGroupCommand,
IPatchGroupCommand patchGroupCommand,
IPostGroupCommand postGroupCommand,
IPutGroupCommand putGroupCommand,
ILogger<GroupsController> logger)
{
_groupRepository = groupRepository;
_organizationRepository = organizationRepository;
_getGroupsListQuery = getGroupsListQuery;
_deleteGroupCommand = deleteGroupCommand;
_patchGroupCommand = patchGroupCommand;
_postGroupCommand = postGroupCommand;
_putGroupCommand = putGroupCommand;
_logger = logger;
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid organizationId, Guid id)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organizationId)
{
throw new NotFoundException("Group not found.");
}
return Ok(new ScimGroupResponseModel(group));
}
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
{
var groupsListQueryResult = await _getGroupsListQuery.GetGroupsListAsync(organizationId, filter, count, startIndex);
var scimListResponseModel = new ScimListResponseModel<ScimGroupResponseModel>
{
Resources = groupsListQueryResult.groupList.Select(g => new ScimGroupResponseModel(g)).ToList(),
ItemsPerPage = count.GetValueOrDefault(groupsListQueryResult.groupList.Count()),
TotalResults = groupsListQueryResult.totalResults,
StartIndex = startIndex.GetValueOrDefault(1),
};
return Ok(scimListResponseModel);
}
[HttpPost("")]
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimGroupRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var group = await _postGroupCommand.PostGroupAsync(organization, model);
var scimGroupResponseModel = new ScimGroupResponseModel(group);
return new CreatedResult(Url.Action(nameof(Get), new { group.OrganizationId, group.Id }), scimGroupResponseModel);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimGroupRequestModel model)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId);
var group = await _putGroupCommand.PutGroupAsync(organization, id, model);
var response = new ScimGroupResponseModel(group);
return Ok(response);
}
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
var organization = await _organizationRepository.GetByIdAsync(organizationId);
await _patchGroupCommand.PatchGroupAsync(organization, id, model);
return new NoContentResult();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
{
await _deleteGroupCommand.DeleteGroupAsync(organizationId, id, EventSystemUser.SCIM);
return new NoContentResult();
}
}

View File

@ -0,0 +1,125 @@
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
using Bit.Scim.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Bit.Scim.Controllers.v2;
[Authorize("Scim")]
[Route("v2/{organizationId}/users")]
[ExceptionHandlerFilter]
public class UsersController : Controller
{
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IGetUsersListQuery _getUsersListQuery;
private readonly IDeleteOrganizationUserCommand _deleteOrganizationUserCommand;
private readonly IPatchUserCommand _patchUserCommand;
private readonly IPostUserCommand _postUserCommand;
private readonly ILogger<UsersController> _logger;
public UsersController(
IUserService userService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IGetUsersListQuery getUsersListQuery,
IDeleteOrganizationUserCommand deleteOrganizationUserCommand,
IPatchUserCommand patchUserCommand,
IPostUserCommand postUserCommand,
ILogger<UsersController> logger)
{
_userService = userService;
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_getUsersListQuery = getUsersListQuery;
_deleteOrganizationUserCommand = deleteOrganizationUserCommand;
_patchUserCommand = patchUserCommand;
_postUserCommand = postUserCommand;
_logger = logger;
}
[HttpGet("{id}")]
public async Task<IActionResult> Get(Guid organizationId, Guid id)
{
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
{
throw new NotFoundException("User not found.");
}
return Ok(new ScimUserResponseModel(orgUser));
}
[HttpGet("")]
public async Task<IActionResult> Get(
Guid organizationId,
[FromQuery] string filter,
[FromQuery] int? count,
[FromQuery] int? startIndex)
{
var usersListQueryResult = await _getUsersListQuery.GetUsersListAsync(organizationId, filter, count, startIndex);
var scimListResponseModel = new ScimListResponseModel<ScimUserResponseModel>
{
Resources = usersListQueryResult.userList.Select(u => new ScimUserResponseModel(u)).ToList(),
ItemsPerPage = count.GetValueOrDefault(usersListQueryResult.userList.Count()),
TotalResults = usersListQueryResult.totalResults,
StartIndex = startIndex.GetValueOrDefault(1),
};
return Ok(scimListResponseModel);
}
[HttpPost("")]
public async Task<IActionResult> Post(Guid organizationId, [FromBody] ScimUserRequestModel model)
{
var orgUser = await _postUserCommand.PostUserAsync(organizationId, model);
var scimUserResponseModel = new ScimUserResponseModel(orgUser);
return new CreatedResult(Url.Action(nameof(Get), new { orgUser.OrganizationId, orgUser.Id }), scimUserResponseModel);
}
[HttpPut("{id}")]
public async Task<IActionResult> Put(Guid organizationId, Guid id, [FromBody] ScimUserRequestModel model)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
{
return new NotFoundObjectResult(new ScimErrorResponseModel
{
Status = 404,
Detail = "User not found."
});
}
if (model.Active && orgUser.Status == OrganizationUserStatusType.Revoked)
{
await _organizationService.RestoreUserAsync(orgUser, null, _userService);
}
else if (!model.Active && orgUser.Status != OrganizationUserStatusType.Revoked)
{
await _organizationService.RevokeUserAsync(orgUser, null);
}
// Have to get full details object for response model
var orgUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
return new ObjectResult(new ScimUserResponseModel(orgUserDetails));
}
[HttpPatch("{id}")]
public async Task<IActionResult> Patch(Guid organizationId, Guid id, [FromBody] ScimPatchModel model)
{
await _patchUserCommand.PatchUserAsync(organizationId, id, model);
return new NoContentResult();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(Guid organizationId, Guid id)
{
await _deleteOrganizationUserCommand.DeleteUserAsync(organizationId, id, EventSystemUser.SCIM);
return new NoContentResult();
}
}

View File

@ -0,0 +1,20 @@
FROM mcr.microsoft.com/dotnet/aspnet:6.0
LABEL com.bitwarden.product="bitwarden"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
gosu \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_URLS http://+:5000
WORKDIR /app
EXPOSE 5000
COPY obj/build-output/publish .
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
HEALTHCHECK CMD curl -f http://localhost:5000/alive || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,64 @@
using Bit.Core.Entities;
using Bit.Core.Repositories;
using Bit.Scim.Groups.Interfaces;
namespace Bit.Scim.Groups;
public class GetGroupsListQuery : IGetGroupsListQuery
{
private readonly IGroupRepository _groupRepository;
public GetGroupsListQuery(IGroupRepository groupRepository)
{
_groupRepository = groupRepository;
}
public async Task<(IEnumerable<Group> groupList, int totalResults)> GetGroupsListAsync(Guid organizationId, string filter, int? count, int? startIndex)
{
string nameFilter = null;
string externalIdFilter = null;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("displayName eq "))
{
nameFilter = filter.Substring(15).Trim('"');
}
else if (filter.StartsWith("externalId eq "))
{
externalIdFilter = filter.Substring(14).Trim('"');
}
}
var groupList = new List<Group>();
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organizationId);
var totalResults = 0;
if (!string.IsNullOrWhiteSpace(nameFilter))
{
var group = groups.FirstOrDefault(g => g.Name == nameFilter);
if (group != null)
{
groupList.Add(group);
}
totalResults = groupList.Count;
}
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
{
var group = groups.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
if (group != null)
{
groupList.Add(group);
}
totalResults = groupList.Count;
}
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
{
groupList = groups.OrderBy(g => g.Name)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.ToList();
totalResults = groups.Count;
}
return (groupList, totalResults);
}
}

View File

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

View File

@ -0,0 +1,9 @@
using Bit.Core.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPatchGroupCommand
{
Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPostGroupCommand
{
Task<Group> PostGroupAsync(Organization organization, ScimGroupRequestModel model);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Entities;
using Bit.Scim.Models;
namespace Bit.Scim.Groups.Interfaces;
public interface IPutGroupCommand
{
Task<Group> PutGroupAsync(Organization organization, Guid id, ScimGroupRequestModel model);
}

View File

@ -0,0 +1,153 @@
using System.Text.Json;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
namespace Bit.Scim.Groups;
public class PatchGroupCommand : IPatchGroupCommand
{
private readonly IGroupRepository _groupRepository;
private readonly IGroupService _groupService;
private readonly IUpdateGroupCommand _updateGroupCommand;
private readonly ILogger<PatchGroupCommand> _logger;
public PatchGroupCommand(
IGroupRepository groupRepository,
IGroupService groupService,
IUpdateGroupCommand updateGroupCommand,
ILogger<PatchGroupCommand> logger)
{
_groupRepository = groupRepository;
_groupService = groupService;
_updateGroupCommand = updateGroupCommand;
_logger = logger;
}
public async Task PatchGroupAsync(Organization organization, Guid id, ScimPatchModel model)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organization.Id)
{
throw new NotFoundException("Group not found.");
}
var operationHandled = false;
foreach (var operation in model.Operations)
{
// Replace operations
if (operation.Op?.ToLowerInvariant() == "replace")
{
// Replace a list of members
if (operation.Path?.ToLowerInvariant() == "members")
{
var ids = GetOperationValueIds(operation.Value);
await _groupRepository.UpdateUsersAsync(group.Id, ids);
operationHandled = true;
}
// Replace group name from path
else if (operation.Path?.ToLowerInvariant() == "displayname")
{
group.Name = operation.Value.GetString();
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true;
}
// Replace group name from value object
else if (string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("displayName", out var displayNameProperty))
{
group.Name = displayNameProperty.GetString();
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
operationHandled = true;
}
}
// Add a single member
else if (operation.Op?.ToLowerInvariant() == "add" &&
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
{
var addId = GetOperationPathId(operation.Path);
if (addId.HasValue)
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
orgUserIds.Add(addId.Value);
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
}
// Add a list of members
else if (operation.Op?.ToLowerInvariant() == "add" &&
operation.Path?.ToLowerInvariant() == "members")
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Add(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
// Remove a single member
else if (operation.Op?.ToLowerInvariant() == "remove" &&
!string.IsNullOrWhiteSpace(operation.Path) &&
operation.Path.ToLowerInvariant().StartsWith("members[value eq "))
{
var removeId = GetOperationPathId(operation.Path);
if (removeId.HasValue)
{
await _groupService.DeleteUserAsync(group, removeId.Value, EventSystemUser.SCIM);
operationHandled = true;
}
}
// Remove a list of members
else if (operation.Op?.ToLowerInvariant() == "remove" &&
operation.Path?.ToLowerInvariant() == "members")
{
var orgUserIds = (await _groupRepository.GetManyUserIdsByIdAsync(group.Id)).ToHashSet();
foreach (var v in GetOperationValueIds(operation.Value))
{
orgUserIds.Remove(v);
}
await _groupRepository.UpdateUsersAsync(group.Id, orgUserIds);
operationHandled = true;
}
}
if (!operationHandled)
{
_logger.LogWarning("Group patch operation not handled: {0} : ",
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
}
}
private List<Guid> GetOperationValueIds(JsonElement objArray)
{
var ids = new List<Guid>();
foreach (var obj in objArray.EnumerateArray())
{
if (obj.TryGetProperty("value", out var valueProperty))
{
if (valueProperty.TryGetGuid(out var guid))
{
ids.Add(guid);
}
}
}
return ids;
}
private Guid? GetOperationPathId(string path)
{
// Parse Guid from string like: members[value eq "{GUID}"}]
if (Guid.TryParse(path.Substring(18).Replace("\"]", string.Empty), out var id))
{
return id;
}
return null;
}
}

View File

@ -0,0 +1,77 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
namespace Bit.Scim.Groups;
public class PostGroupCommand : IPostGroupCommand
{
private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly ICreateGroupCommand _createGroupCommand;
public PostGroupCommand(
IGroupRepository groupRepository,
IOrganizationRepository organizationRepository,
IScimContext scimContext,
ICreateGroupCommand createGroupCommand)
{
_groupRepository = groupRepository;
_scimContext = scimContext;
_createGroupCommand = createGroupCommand;
}
public async Task<Group> PostGroupAsync(Organization organization, ScimGroupRequestModel model)
{
if (string.IsNullOrWhiteSpace(model.DisplayName))
{
throw new BadRequestException();
}
var groups = await _groupRepository.GetManyByOrganizationIdAsync(organization.Id);
if (!string.IsNullOrWhiteSpace(model.ExternalId) && groups.Any(g => g.ExternalId == model.ExternalId))
{
throw new ConflictException();
}
var group = model.ToGroup(organization.Id);
await _createGroupCommand.CreateGroupAsync(group, organization, EventSystemUser.SCIM, collections: null);
await UpdateGroupMembersAsync(group, model);
return group;
}
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
{
return;
}
if (model.Members == null)
{
return;
}
var memberIds = new List<Guid>();
foreach (var id in model.Members.Select(i => i.Value))
{
if (Guid.TryParse(id, out var guidId))
{
memberIds.Add(guidId);
}
}
if (!memberIds.Any())
{
return;
}
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
}
}

View File

@ -0,0 +1,66 @@
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.OrganizationFeatures.Groups.Interfaces;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using Bit.Scim.Groups.Interfaces;
using Bit.Scim.Models;
namespace Bit.Scim.Groups;
public class PutGroupCommand : IPutGroupCommand
{
private readonly IGroupRepository _groupRepository;
private readonly IScimContext _scimContext;
private readonly IUpdateGroupCommand _updateGroupCommand;
public PutGroupCommand(
IGroupRepository groupRepository,
IScimContext scimContext,
IUpdateGroupCommand updateGroupCommand)
{
_groupRepository = groupRepository;
_scimContext = scimContext;
_updateGroupCommand = updateGroupCommand;
}
public async Task<Group> PutGroupAsync(Organization organization, Guid id, ScimGroupRequestModel model)
{
var group = await _groupRepository.GetByIdAsync(id);
if (group == null || group.OrganizationId != organization.Id)
{
throw new NotFoundException("Group not found.");
}
group.Name = model.DisplayName;
await _updateGroupCommand.UpdateGroupAsync(group, organization, EventSystemUser.SCIM);
await UpdateGroupMembersAsync(group, model);
return group;
}
private async Task UpdateGroupMembersAsync(Group group, ScimGroupRequestModel model)
{
if (_scimContext.RequestScimProvider != Core.Enums.ScimProviderType.Okta)
{
return;
}
if (model.Members == null)
{
return;
}
var memberIds = new List<Guid>();
foreach (var id in model.Members.Select(i => i.Value))
{
if (Guid.TryParse(id, out var guidId))
{
memberIds.Add(guidId);
}
}
await _groupRepository.UpdateUsersAsync(group.Id, memberIds);
}
}

View File

@ -0,0 +1,17 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;
public abstract class BaseScimGroupModel : BaseScimModel
{
public BaseScimGroupModel(bool initSchema = false)
{
if (initSchema)
{
Schemas = new List<string> { ScimConstants.Scim2SchemaGroup };
}
}
public string DisplayName { get; set; }
public string ExternalId { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace Bit.Scim.Models;
public abstract class BaseScimModel
{
public BaseScimModel()
{ }
public BaseScimModel(string schema)
{
Schemas = new List<string> { schema };
}
public List<string> Schemas { get; set; }
}

View File

@ -0,0 +1,55 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;
public abstract class BaseScimUserModel : BaseScimModel
{
public BaseScimUserModel(bool initSchema = false)
{
if (initSchema)
{
Schemas = new List<string> { ScimConstants.Scim2SchemaUser };
}
}
public string UserName { get; set; }
public NameModel Name { get; set; }
public List<EmailModel> Emails { get; set; }
public string PrimaryEmail => Emails?.FirstOrDefault(e => e.Primary)?.Value;
public string WorkEmail => Emails?.FirstOrDefault(e => e.Type == "work")?.Value;
public string DisplayName { get; set; }
public bool Active { get; set; }
public List<string> Groups { get; set; }
public string ExternalId { get; set; }
public class NameModel
{
public NameModel() { }
public NameModel(string name)
{
Formatted = name;
}
public string Formatted { get; set; }
public string GivenName { get; set; }
public string MiddleName { get; set; }
public string FamilyName { get; set; }
}
public class EmailModel
{
public EmailModel() { }
public EmailModel(string email)
{
Primary = true;
Value = email;
Type = "work";
}
public bool Primary { get; set; }
public string Value { get; set; }
public string Type { get; set; }
}
}

View File

@ -0,0 +1,13 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;
public class ScimErrorResponseModel : BaseScimModel
{
public ScimErrorResponseModel()
: base(ScimConstants.Scim2SchemaError)
{ }
public string Detail { get; set; }
public int Status { get; set; }
}

View File

@ -0,0 +1,30 @@
using Bit.Core.Entities;
using Bit.Core.Utilities;
namespace Bit.Scim.Models;
public class ScimGroupRequestModel : BaseScimGroupModel
{
public ScimGroupRequestModel()
: base(false)
{ }
public Group ToGroup(Guid organizationId)
{
var externalId = string.IsNullOrWhiteSpace(ExternalId) ? CoreHelpers.RandomString(15) : ExternalId;
return new Group
{
Name = DisplayName,
ExternalId = externalId,
OrganizationId = organizationId
};
}
public List<GroupMembersModel> Members { get; set; }
public class GroupMembersModel
{
public string Value { get; set; }
public string Display { get; set; }
}
}

View File

@ -0,0 +1,25 @@
using Bit.Core.Entities;
namespace Bit.Scim.Models;
public class ScimGroupResponseModel : BaseScimGroupModel
{
public ScimGroupResponseModel()
: base(true)
{
Meta = new ScimMetaModel("Group");
}
public ScimGroupResponseModel(Group group)
: this()
{
Id = group.Id.ToString();
DisplayName = group.Name;
ExternalId = group.ExternalId;
Meta.Created = group.CreationDate;
Meta.LastModified = group.RevisionDate;
}
public string Id { get; set; }
public ScimMetaModel Meta { get; private set; }
}

View File

@ -0,0 +1,15 @@
using Bit.Scim.Utilities;
namespace Bit.Scim.Models;
public class ScimListResponseModel<T> : BaseScimModel
{
public ScimListResponseModel()
: base(ScimConstants.Scim2SchemaListResponse)
{ }
public int TotalResults { get; set; }
public int StartIndex { get; set; }
public int ItemsPerPage { get; set; }
public List<T> Resources { get; set; }
}

View File

@ -0,0 +1,13 @@
namespace Bit.Scim.Models;
public class ScimMetaModel
{
public ScimMetaModel(string resourceType)
{
ResourceType = resourceType;
}
public string ResourceType { get; set; }
public DateTime? Created { get; set; }
public DateTime? LastModified { get; set; }
}

View File

@ -0,0 +1,18 @@
using System.Text.Json;
namespace Bit.Scim.Models;
public class ScimPatchModel : BaseScimModel
{
public ScimPatchModel()
: base() { }
public List<OperationModel> Operations { get; set; }
public class OperationModel
{
public string Op { get; set; }
public string Path { get; set; }
public JsonElement Value { get; set; }
}
}

View File

@ -0,0 +1,8 @@
namespace Bit.Scim.Models;
public class ScimUserRequestModel : BaseScimUserModel
{
public ScimUserRequestModel()
: base(false)
{ }
}

View File

@ -0,0 +1,28 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Scim.Models;
public class ScimUserResponseModel : BaseScimUserModel
{
public ScimUserResponseModel()
: base(true)
{
Meta = new ScimMetaModel("User");
Groups = new List<string>();
}
public ScimUserResponseModel(OrganizationUserUserDetails orgUser)
: this()
{
Id = orgUser.Id.ToString();
ExternalId = orgUser.ExternalId;
UserName = orgUser.Email;
DisplayName = orgUser.Name;
Emails = new List<EmailModel> { new EmailModel(orgUser.Email) };
Name = new NameModel(orgUser.Name);
Active = orgUser.Status != Core.Enums.OrganizationUserStatusType.Revoked;
}
public string Id { get; set; }
public ScimMetaModel Meta { get; private set; }
}

View File

@ -0,0 +1,32 @@
using Bit.Core.Utilities;
namespace Bit.Scim;
public class Program
{
public static void Main(string[] args)
{
Host
.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
webBuilder.ConfigureLogging((hostingContext, logging) =>
logging.AddSerilog(hostingContext, (e, globalSettings) =>
{
var context = e.Properties["SourceContext"].ToString();
if (e.Properties.ContainsKey("RequestPath") &&
!string.IsNullOrWhiteSpace(e.Properties["RequestPath"]?.ToString()) &&
(context.Contains(".Server.Kestrel") || context.Contains(".Core.IISHttpServer")))
{
return false;
}
return e.Level >= globalSettings.MinLogLevel.ScimSettings.Default;
}));
})
.Build()
.Run();
}
}

View File

@ -0,0 +1,29 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:44558/",
"sslPort": 0
}
},
"profiles": {
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": false,
"launchUrl": "http://localhost:44558",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"Scim": {
"commandName": "Project",
"launchBrowser": false,
"launchUrl": "http://localhost:44558",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:44559"
}
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<UserSecretsId>bitwarden-Scim</UserSecretsId>
<MvcRazorCompileOnPublish>false</MvcRazorCompileOnPublish>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="5.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\src\Core\Core.csproj" />
<ProjectReference Include="..\..\..\src\SharedWeb\SharedWeb.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,5 @@
namespace Bit.Scim;
public class ScimSettings
{
}

View File

@ -0,0 +1,120 @@
using System.Globalization;
using Bit.Core.Context;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Utilities;
using Bit.SharedWeb.Utilities;
using IdentityModel;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Stripe;
namespace Bit.Scim;
public class Startup
{
public Startup(IWebHostEnvironment env, IConfiguration configuration)
{
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
Configuration = configuration;
Environment = env;
}
public IConfiguration Configuration { get; }
public IWebHostEnvironment Environment { get; set; }
public void ConfigureServices(IServiceCollection services)
{
// Options
services.AddOptions();
// Settings
var globalSettings = services.AddGlobalSettingsServices(Configuration, Environment);
services.Configure<ScimSettings>(Configuration.GetSection("ScimSettings"));
// Data Protection
services.AddCustomDataProtectionServices(Environment, globalSettings);
// Stripe Billing
StripeConfiguration.ApiKey = globalSettings.Stripe.ApiKey;
StripeConfiguration.MaxNetworkRetries = globalSettings.Stripe.MaxNetworkRetries;
// Repositories
services.AddDatabaseRepositories(globalSettings);
// Context
services.AddScoped<ICurrentContext, CurrentContext>();
services.AddScoped<IScimContext, ScimContext>();
// Authentication
services.AddAuthentication(ApiKeyAuthenticationOptions.DefaultScheme)
.AddScheme<ApiKeyAuthenticationOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationOptions.DefaultScheme, null);
services.AddAuthorization(config =>
{
config.AddPolicy("Scim", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireClaim(JwtClaimTypes.Scope, "api.scim");
});
});
// Identity
services.AddCustomIdentityServices(globalSettings);
// Services
services.AddBaseServices(globalSettings);
services.AddDefaultServices(globalSettings);
services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
// Mvc
services.AddMvc(config =>
{
config.Filters.Add(new LoggingExceptionHandlerFilterAttribute());
});
services.Configure<RouteOptions>(options => options.LowercaseUrls = true);
services.AddScimGroupCommands();
services.AddScimGroupQueries();
services.AddScimUserQueries();
services.AddScimUserCommands();
}
public void Configure(
IApplicationBuilder app,
IWebHostEnvironment env,
IHostApplicationLifetime appLifetime,
GlobalSettings globalSettings)
{
app.UseSerilog(env, appLifetime, globalSettings);
// Add general security headers
app.UseMiddleware<SecurityHeadersMiddleware>();
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
// Default Middleware
app.UseDefaultMiddleware(env, globalSettings);
// Add routing
app.UseRouting();
// Add Scim context
app.UseMiddleware<ScimContextMiddleware>();
// Add authentication and authorization to the request pipeline.
app.UseAuthentication();
app.UseAuthorization();
// Add current context
app.UseMiddleware<CurrentContextMiddleware>();
// Add MVC to the request pipeline.
app.UseEndpoints(endpoints => endpoints.MapDefaultControllerRoute());
}
}

View File

@ -0,0 +1,69 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Scim.Users.Interfaces;
namespace Bit.Scim.Users;
public class GetUsersListQuery : IGetUsersListQuery
{
private readonly IOrganizationUserRepository _organizationUserRepository;
public GetUsersListQuery(IOrganizationUserRepository organizationUserRepository)
{
_organizationUserRepository = organizationUserRepository;
}
public async Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex)
{
string emailFilter = null;
string usernameFilter = null;
string externalIdFilter = null;
if (!string.IsNullOrWhiteSpace(filter))
{
if (filter.StartsWith("userName eq "))
{
usernameFilter = filter.Substring(12).Trim('"').ToLowerInvariant();
if (usernameFilter.Contains("@"))
{
emailFilter = usernameFilter;
}
}
else if (filter.StartsWith("externalId eq "))
{
externalIdFilter = filter.Substring(14).Trim('"');
}
}
var userList = new List<OrganizationUserUserDetails>();
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var totalResults = 0;
if (!string.IsNullOrWhiteSpace(emailFilter))
{
var orgUser = orgUsers.FirstOrDefault(ou => ou.Email.ToLowerInvariant() == emailFilter);
if (orgUser != null)
{
userList.Add(orgUser);
}
totalResults = userList.Count;
}
else if (!string.IsNullOrWhiteSpace(externalIdFilter))
{
var orgUser = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalIdFilter);
if (orgUser != null)
{
userList.Add(orgUser);
}
totalResults = userList.Count;
}
else if (string.IsNullOrWhiteSpace(filter) && startIndex.HasValue && count.HasValue)
{
userList = orgUsers.OrderBy(ou => ou.Email)
.Skip(startIndex.Value - 1)
.Take(count.Value)
.ToList();
totalResults = orgUsers.Count;
}
return (userList, totalResults);
}
}

View File

@ -0,0 +1,8 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
namespace Bit.Scim.Users.Interfaces;
public interface IGetUsersListQuery
{
Task<(IEnumerable<OrganizationUserUserDetails> userList, int totalResults)> GetUsersListAsync(Guid organizationId, string filter, int? count, int? startIndex);
}

View File

@ -0,0 +1,8 @@
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;
public interface IPatchUserCommand
{
Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model);
}

View File

@ -0,0 +1,9 @@
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Scim.Models;
namespace Bit.Scim.Users.Interfaces;
public interface IPostUserCommand
{
Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model);
}

View File

@ -0,0 +1,87 @@
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
namespace Bit.Scim.Users;
public class PatchUserCommand : IPatchUserCommand
{
private readonly IUserService _userService;
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly ILogger<PatchUserCommand> _logger;
public PatchUserCommand(
IUserService userService,
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
ILogger<PatchUserCommand> logger)
{
_userService = userService;
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_logger = logger;
}
public async Task PatchUserAsync(Guid organizationId, Guid id, ScimPatchModel model)
{
var orgUser = await _organizationUserRepository.GetByIdAsync(id);
if (orgUser == null || orgUser.OrganizationId != organizationId)
{
throw new NotFoundException("User not found.");
}
var operationHandled = false;
foreach (var operation in model.Operations)
{
// Replace operations
if (operation.Op?.ToLowerInvariant() == "replace")
{
// Active from path
if (operation.Path?.ToLowerInvariant() == "active")
{
var active = operation.Value.ToString()?.ToLowerInvariant();
var handled = await HandleActiveOperationAsync(orgUser, active == "true");
if (!operationHandled)
{
operationHandled = handled;
}
}
// Active from value object
else if (string.IsNullOrWhiteSpace(operation.Path) &&
operation.Value.TryGetProperty("active", out var activeProperty))
{
var handled = await HandleActiveOperationAsync(orgUser, activeProperty.GetBoolean());
if (!operationHandled)
{
operationHandled = handled;
}
}
}
}
if (!operationHandled)
{
_logger.LogWarning("User patch operation not handled: {operation} : ",
string.Join(", ", model.Operations.Select(o => $"{o.Op}:{o.Path}")));
}
}
private async Task<bool> HandleActiveOperationAsync(Core.Entities.OrganizationUser orgUser, bool active)
{
if (active && orgUser.Status == OrganizationUserStatusType.Revoked)
{
await _organizationService.RestoreUserAsync(orgUser, EventSystemUser.SCIM, _userService);
return true;
}
else if (!active && orgUser.Status != OrganizationUserStatusType.Revoked)
{
await _organizationService.RevokeUserAsync(orgUser, EventSystemUser.SCIM);
return true;
}
return false;
}
}

View File

@ -0,0 +1,88 @@
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Utilities;
using Bit.Scim.Context;
using Bit.Scim.Models;
using Bit.Scim.Users.Interfaces;
namespace Bit.Scim.Users;
public class PostUserCommand : IPostUserCommand
{
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IOrganizationService _organizationService;
private readonly IScimContext _scimContext;
public PostUserCommand(
IOrganizationUserRepository organizationUserRepository,
IOrganizationService organizationService,
IScimContext scimContext)
{
_organizationUserRepository = organizationUserRepository;
_organizationService = organizationService;
_scimContext = scimContext;
}
public async Task<OrganizationUserUserDetails> PostUserAsync(Guid organizationId, ScimUserRequestModel model)
{
var email = model.PrimaryEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
switch (_scimContext.RequestScimProvider)
{
case ScimProviderType.AzureAd:
email = model.UserName?.ToLowerInvariant();
break;
default:
email = model.WorkEmail?.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(email))
{
email = model.Emails?.FirstOrDefault()?.Value?.ToLowerInvariant();
}
break;
}
}
if (string.IsNullOrWhiteSpace(email) || !model.Active)
{
throw new BadRequestException();
}
var orgUsers = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(organizationId);
var orgUserByEmail = orgUsers.FirstOrDefault(ou => ou.Email?.ToLowerInvariant() == email);
if (orgUserByEmail != null)
{
throw new ConflictException();
}
string externalId = null;
if (!string.IsNullOrWhiteSpace(model.ExternalId))
{
externalId = model.ExternalId;
}
else if (!string.IsNullOrWhiteSpace(model.UserName))
{
externalId = model.UserName;
}
else
{
externalId = CoreHelpers.RandomString(15);
}
var orgUserByExternalId = orgUsers.FirstOrDefault(ou => ou.ExternalId == externalId);
if (orgUserByExternalId != null)
{
throw new ConflictException();
}
var invitedOrgUser = await _organizationService.InviteUserAsync(organizationId, EventSystemUser.SCIM, email,
OrganizationUserType.User, false, externalId, new List<CollectionAccessSelection>(), new List<Guid>());
var orgUser = await _organizationUserRepository.GetDetailsByIdAsync(invitedOrgUser.Id);
return orgUser;
}
}

View File

@ -0,0 +1,89 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Scim.Context;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
namespace Bit.Scim.Utilities;
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
private readonly IOrganizationRepository _organizationRepository;
private readonly IOrganizationApiKeyRepository _organizationApiKeyRepository;
private readonly IScimContext _scimContext;
public ApiKeyAuthenticationHandler(
IOptionsMonitor<ApiKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock,
IOrganizationRepository organizationRepository,
IOrganizationApiKeyRepository organizationApiKeyRepository,
IScimContext scimContext) :
base(options, logger, encoder, clock)
{
_organizationRepository = organizationRepository;
_organizationApiKeyRepository = organizationApiKeyRepository;
_scimContext = scimContext;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var endpoint = Context.GetEndpoint();
if (endpoint?.Metadata?.GetMetadata<IAllowAnonymous>() != null)
{
return AuthenticateResult.NoResult();
}
if (!_scimContext.OrganizationId.HasValue || _scimContext.Organization == null)
{
Logger.LogWarning("No organization.");
return AuthenticateResult.Fail("Invalid parameters");
}
if (!Request.Headers.TryGetValue("Authorization", out var authHeader) || authHeader.Count != 1)
{
Logger.LogWarning("An API request was received without the Authorization header");
return AuthenticateResult.Fail("Invalid parameters");
}
var apiKey = authHeader.ToString();
if (apiKey.StartsWith("Bearer "))
{
apiKey = apiKey.Substring(7);
}
if (!_scimContext.Organization.Enabled || !_scimContext.Organization.UseScim ||
_scimContext.ScimConfiguration == null || !_scimContext.ScimConfiguration.Enabled)
{
Logger.LogInformation("Org {organizationId} not able to use Scim.", _scimContext.OrganizationId);
return AuthenticateResult.Fail("Invalid parameters");
}
var orgApiKey = (await _organizationApiKeyRepository
.GetManyByOrganizationIdTypeAsync(_scimContext.Organization.Id, OrganizationApiKeyType.Scim))
.FirstOrDefault();
if (orgApiKey?.ApiKey != apiKey)
{
Logger.LogWarning("An API request was received with an invalid API key: {apiKey}", apiKey);
return AuthenticateResult.Fail("Invalid parameters");
}
Logger.LogInformation("Org {organizationId} authenticated", _scimContext.OrganizationId);
var claims = new[]
{
new Claim(JwtClaimTypes.ClientId, $"organization.{_scimContext.OrganizationId.Value}"),
new Claim("client_sub", _scimContext.OrganizationId.Value.ToString()),
new Claim(JwtClaimTypes.Scope, "api.scim"),
};
var identity = new ClaimsIdentity(claims, nameof(ApiKeyAuthenticationHandler));
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity),
ApiKeyAuthenticationOptions.DefaultScheme);
return AuthenticateResult.Success(ticket);
}
}

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