1
0
mirror of https://github.com/bitwarden/server synced 2025-12-13 06:43:45 +00:00

Merge remote-tracking branch 'origin/main' into arch/seeder-api

This commit is contained in:
Matt Gibson
2025-11-04 21:43:51 -08:00
258 changed files with 23154 additions and 2234 deletions

View File

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

5
.github/CODEOWNERS vendored
View File

@@ -102,3 +102,8 @@ util/RustSdk @bitwarden/team-sdk-sme
# Multiple owners - DO NOT REMOVE (BRE) # Multiple owners - DO NOT REMOVE (BRE)
**/packages.lock.json **/packages.lock.json
Directory.Build.props Directory.Build.props
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme

View File

@@ -41,18 +41,19 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
- name: Get script prefix - name: Get script prefix
id: prefix id: prefix
run: echo "prefix=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT run: echo "prefix=$(date +'%Y-%m-%d')" >> "$GITHUB_OUTPUT"
- name: Check if any files in DB transition or finalization directories - name: Check if any files in DB transition or finalization directories
id: check-script-existence id: check-script-existence
run: | run: |
if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then if [ -f util/Migrator/DbScripts_transition/* -o -f util/Migrator/DbScripts_finalization/* ]; then
echo "copy_edd_scripts=true" >> $GITHUB_OUTPUT echo "copy_edd_scripts=true" >> "$GITHUB_OUTPUT"
else else
echo "copy_edd_scripts=false" >> $GITHUB_OUTPUT echo "copy_edd_scripts=false" >> "$GITHUB_OUTPUT"
fi fi
move-scripts: move-scripts:
@@ -70,17 +71,18 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: true
- name: Generate branch name - name: Generate branch name
id: branch_name id: branch_name
env: env:
PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }} PREFIX: ${{ needs.setup.outputs.migration_filename_prefix }}
run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> $GITHUB_OUTPUT run: echo "branch_name=move_edd_db_scripts_$PREFIX" >> "$GITHUB_OUTPUT"
- name: "Create branch" - name: "Create branch"
env: env:
BRANCH: ${{ steps.branch_name.outputs.branch_name }} BRANCH: ${{ steps.branch_name.outputs.branch_name }}
run: git switch -c $BRANCH run: git switch -c "$BRANCH"
- name: Move scripts and finalization database schema - name: Move scripts and finalization database schema
id: move-files id: move-files
@@ -120,7 +122,7 @@ jobs:
# sync finalization schema back to dbo, maintaining structure # sync finalization schema back to dbo, maintaining structure
rsync -r "$src_dir/" "$dest_dir/" rsync -r "$src_dir/" "$dest_dir/"
rm -rf $src_dir/* rm -rf "${src_dir}"/*
# Replace any finalization references due to the move # Replace any finalization references due to the move
find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \ find ./src/Sql/dbo -name "*.sql" -type f -exec sed -i \
@@ -131,7 +133,7 @@ jobs:
moved_files="$moved_files \n $file" moved_files="$moved_files \n $file"
done done
echo "moved_files=$moved_files" >> $GITHUB_OUTPUT echo "moved_files=$moved_files" >> "$GITHUB_OUTPUT"
- name: Log in to Azure - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -162,18 +164,20 @@ jobs:
- name: Commit and push changes - name: Commit and push changes
id: commit id: commit
env:
BRANCH_NAME: ${{ steps.branch_name.outputs.branch_name }}
run: | run: |
git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com" git config --local user.email "106330231+bitwarden-devops-bot@users.noreply.github.com"
git config --local user.name "bitwarden-devops-bot" git config --local user.name "bitwarden-devops-bot"
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then
git add . git add .
git commit -m "Move EDD database scripts" -a git commit -m "Move EDD database scripts" -a
git push -u origin ${{ steps.branch_name.outputs.branch_name }} git push -u origin "${BRANCH_NAME}"
echo "pr_needed=true" >> $GITHUB_OUTPUT echo "pr_needed=true" >> "$GITHUB_OUTPUT"
else else
echo "No changes to commit!"; echo "No changes to commit!";
echo "pr_needed=false" >> $GITHUB_OUTPUT echo "pr_needed=false" >> "$GITHUB_OUTPUT"
echo "### :mega: No changes to commit! PR was ommited." >> $GITHUB_STEP_SUMMARY echo "### :mega: No changes to commit! PR was ommited." >> "$GITHUB_STEP_SUMMARY"
fi fi
- name: Create PR for ${{ steps.branch_name.outputs.branch_name }} - name: Create PR for ${{ steps.branch_name.outputs.branch_name }}
@@ -195,7 +199,7 @@ jobs:
Files moved: Files moved:
$(echo -e "$MOVED_FILES") $(echo -e "$MOVED_FILES")
") ")
echo "pr_url=${PR_URL}" >> $GITHUB_OUTPUT echo "pr_url=${PR_URL}" >> "$GITHUB_OUTPUT"
- name: Notify Slack about creation of PR - name: Notify Slack about creation of PR
if: ${{ steps.commit.outputs.pr_needed == 'true' }} if: ${{ steps.commit.outputs.pr_needed == 'true' }}

View File

@@ -28,6 +28,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
@@ -97,23 +98,24 @@ jobs:
id: check-secrets id: check-secrets
run: | run: |
has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }} has_secrets=${{ secrets.AZURE_CLIENT_ID != '' }}
echo "has_secrets=$has_secrets" >> $GITHUB_OUTPUT echo "has_secrets=$has_secrets" >> "$GITHUB_OUTPUT"
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Check branch to publish - name: Check branch to publish
env: env:
PUBLISH_BRANCHES: "main,rc,hotfix-rc" PUBLISH_BRANCHES: "main,rc,hotfix-rc"
id: publish-branch-check id: publish-branch-check
run: | run: |
IFS="," read -a publish_branches <<< $PUBLISH_BRANCHES IFS="," read -a publish_branches <<< "$PUBLISH_BRANCHES"
if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then if [[ " ${publish_branches[*]} " =~ " ${GITHUB_REF:11} " ]]; then
echo "is_publish_branch=true" >> $GITHUB_ENV echo "is_publish_branch=true" >> "$GITHUB_ENV"
else else
echo "is_publish_branch=false" >> $GITHUB_ENV echo "is_publish_branch=false" >> "$GITHUB_ENV"
fi fi
- name: Set up .NET - name: Set up .NET
@@ -209,8 +211,8 @@ jobs:
IMAGE_TAG=dev IMAGE_TAG=dev
fi fi
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> $GITHUB_STEP_SUMMARY echo "### :mega: Docker Image Tag: $IMAGE_TAG" >> "$GITHUB_STEP_SUMMARY"
- name: Set up project name - name: Set up project name
id: setup id: setup
@@ -218,7 +220,7 @@ jobs:
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}" echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME" echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
- name: Generate image tags(s) - name: Generate image tags(s)
id: image-tags id: image-tags
@@ -228,12 +230,12 @@ jobs:
SHA: ${{ github.sha }} SHA: ${{ github.sha }}
run: | run: |
TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}" TAGS="${_AZ_REGISTRY}/${PROJECT_NAME}:${IMAGE_TAG}"
echo "primary_tag=$TAGS" >> $GITHUB_OUTPUT echo "primary_tag=$TAGS" >> "$GITHUB_OUTPUT"
if [[ "${IMAGE_TAG}" == "dev" ]]; then if [[ "${IMAGE_TAG}" == "dev" ]]; then
SHORT_SHA=$(git rev-parse --short ${SHA}) SHORT_SHA=$(git rev-parse --short "${SHA}")
TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}" TAGS=$TAGS",${_AZ_REGISTRY}/${PROJECT_NAME}:dev-${SHORT_SHA}"
fi fi
echo "tags=$TAGS" >> $GITHUB_OUTPUT echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
- name: Build Docker image - name: Build Docker image
id: build-artifacts id: build-artifacts
@@ -260,12 +262,13 @@ jobs:
DIGEST: ${{ steps.build-artifacts.outputs.digest }} DIGEST: ${{ steps.build-artifacts.outputs.digest }}
TAGS: ${{ steps.image-tags.outputs.tags }} TAGS: ${{ steps.image-tags.outputs.tags }}
run: | run: |
IFS="," read -a tags <<< "${TAGS}" IFS=',' read -r -a tags_array <<< "${TAGS}"
images="" images=()
for tag in "${tags[@]}"; do for tag in "${tags_array[@]}"; do
images+="${tag}@${DIGEST} " images+=("${tag}@${DIGEST}")
done done
cosign sign --yes ${images} cosign sign --yes ${images[@]}
echo "images=${images[*]}" >> "$GITHUB_OUTPUT"
- name: Scan Docker image - name: Scan Docker image
id: container-scan id: container-scan
@@ -297,6 +300,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
@@ -309,7 +313,7 @@ jobs:
client_id: ${{ secrets.AZURE_CLIENT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to ACR - production subscription - name: Log in to ACR - production subscription
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
- name: Make Docker stubs - name: Make Docker stubs
if: | if: |
@@ -332,26 +336,26 @@ jobs:
STUB_OUTPUT=$(pwd)/docker-stub STUB_OUTPUT=$(pwd)/docker-stub
# Run setup # Run setup
docker run -i --rm --name setup -v $STUB_OUTPUT/US:/bitwarden $SETUP_IMAGE \ docker run -i --rm --name setup -v "$STUB_OUTPUT/US:/bitwarden" "$SETUP_IMAGE" \
/app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region US
docker run -i --rm --name setup -v $STUB_OUTPUT/EU:/bitwarden $SETUP_IMAGE \ docker run -i --rm --name setup -v "$STUB_OUTPUT/EU:/bitwarden" "$SETUP_IMAGE" \
/app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU /app/Setup -stub 1 -install 1 -domain bitwarden.example.com -os lin -cloud-region EU
sudo chown -R $(whoami):$(whoami) $STUB_OUTPUT sudo chown -R "$(whoami):$(whoami)" "$STUB_OUTPUT"
# Remove extra directories and files # Remove extra directories and files
rm -rf $STUB_OUTPUT/US/letsencrypt rm -rf "$STUB_OUTPUT/US/letsencrypt"
rm -rf $STUB_OUTPUT/EU/letsencrypt rm -rf "$STUB_OUTPUT/EU/letsencrypt"
rm $STUB_OUTPUT/US/env/uid.env $STUB_OUTPUT/US/config.yml rm "$STUB_OUTPUT/US/env/uid.env" "$STUB_OUTPUT/US/config.yml"
rm $STUB_OUTPUT/EU/env/uid.env $STUB_OUTPUT/EU/config.yml rm "$STUB_OUTPUT/EU/env/uid.env" "$STUB_OUTPUT/EU/config.yml"
# Create uid environment files # Create uid environment files
touch $STUB_OUTPUT/US/env/uid.env touch "$STUB_OUTPUT/US/env/uid.env"
touch $STUB_OUTPUT/EU/env/uid.env touch "$STUB_OUTPUT/EU/env/uid.env"
# Zip up the Docker stub files # Zip up the Docker stub files
cd docker-stub/US; zip -r ../../docker-stub-US.zip *; cd ../.. cd docker-stub/US; zip -r ../../docker-stub-US.zip ./*; cd ../..
cd docker-stub/EU; zip -r ../../docker-stub-EU.zip *; cd ../.. cd docker-stub/EU; zip -r ../../docker-stub-EU.zip ./*; cd ../..
- name: Log out from Azure - name: Log out from Azure
uses: bitwarden/gh-actions/azure-logout@main uses: bitwarden/gh-actions/azure-logout@main
@@ -423,6 +427,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0

View File

@@ -22,7 +22,7 @@ jobs:
client_id: ${{ secrets.AZURE_CLIENT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR - name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
########## Remove Docker images ########## ########## Remove Docker images ##########
- name: Remove the Docker image from ACR - name: Remove the Docker image from ACR
@@ -45,20 +45,20 @@ jobs:
- Setup - Setup
- Sso - Sso
run: | run: |
for SERVICE in $(echo "${{ env.SERVICES }}" | yq e ".services[]" - ) for SERVICE in $(echo "${SERVICES}" | yq e ".services[]" - )
do do
SERVICE_NAME=$(echo $SERVICE | awk '{print tolower($0)}') SERVICE_NAME=$(echo "$SERVICE" | awk '{print tolower($0)}')
IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name IMAGE_TAG=$(echo "${REF}" | sed "s#/#-#g") # slash safe branch name
echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG" echo "[*] Checking if remote exists: $_AZ_REGISTRY/$SERVICE_NAME:$IMAGE_TAG"
TAG_EXISTS=$( TAG_EXISTS=$(
az acr repository show-tags --name $_AZ_REGISTRY --repository $SERVICE_NAME \ az acr repository show-tags --name "$_AZ_REGISTRY" --repository "$SERVICE_NAME" \
| jq --arg $TAG "$IMAGE_TAG" -e '. | any(. == "$TAG")' | jq --arg TAG "$IMAGE_TAG" -e '. | any(. == $TAG)'
) )
if [[ "$TAG_EXISTS" == "true" ]]; then if [[ "$TAG_EXISTS" == "true" ]]; then
echo "[*] Tag exists. Removing tag" echo "[*] Tag exists. Removing tag"
az acr repository delete --name $_AZ_REGISTRY --image $SERVICE_NAME:$IMAGE_TAG --yes az acr repository delete --name "$_AZ_REGISTRY" --image "$SERVICE_NAME:$IMAGE_TAG" --yes
else else
echo "[*] Tag does not exist. No action needed" echo "[*] Tag does not exist. No action needed"
fi fi

View File

@@ -35,6 +35,8 @@ jobs:
with: with:
ref: main ref: main
token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }} token: ${{ steps.retrieve-bot-secrets.outputs.github-pat-bitwarden-devops-bot-repo-scope }}
persist-credentials: false
fetch-depth: 0
- name: Check if a RC branch exists - name: Check if a RC branch exists
id: branch-check id: branch-check
@@ -43,11 +45,11 @@ jobs:
rc_branch_check=$(git ls-remote --heads origin rc | wc -l) rc_branch_check=$(git ls-remote --heads origin rc | wc -l)
if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then if [[ "${hotfix_rc_branch_check}" -gt 0 ]]; then
echo "hotfix-rc branch exists." | tee -a $GITHUB_STEP_SUMMARY echo "hotfix-rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
echo "name=hotfix-rc" >> $GITHUB_OUTPUT echo "name=hotfix-rc" >> "$GITHUB_OUTPUT"
elif [[ "${rc_branch_check}" -gt 0 ]]; then elif [[ "${rc_branch_check}" -gt 0 ]]; then
echo "rc branch exists." | tee -a $GITHUB_STEP_SUMMARY echo "rc branch exists." | tee -a "$GITHUB_STEP_SUMMARY"
echo "name=rc" >> $GITHUB_OUTPUT echo "name=rc" >> "$GITHUB_OUTPUT"
fi fi
- name: Delete RC branch - name: Delete RC branch
@@ -55,6 +57,6 @@ jobs:
BRANCH_NAME: ${{ steps.branch-check.outputs.name }} BRANCH_NAME: ${{ steps.branch-check.outputs.name }}
run: | run: |
if ! [[ -z "$BRANCH_NAME" ]]; then if ! [[ -z "$BRANCH_NAME" ]]; then
git push --quiet origin --delete $BRANCH_NAME git push --quiet origin --delete "$BRANCH_NAME"
echo "Deleted $BRANCH_NAME branch." | tee -a $GITHUB_STEP_SUMMARY echo "Deleted $BRANCH_NAME branch." | tee -a "$GITHUB_STEP_SUMMARY"
fi fi

View File

@@ -19,9 +19,9 @@ jobs:
id: check-secret-access id: check-secret-access
run: | run: |
if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then if [ "${{ secrets.AZURE_CLIENT_ID }}" != '' ]; then
echo "available=true" >> $GITHUB_OUTPUT; echo "available=true" >> "$GITHUB_OUTPUT";
else else
echo "available=false" >> $GITHUB_OUTPUT; echo "available=false" >> "$GITHUB_OUTPUT";
fi fi
refs: refs:
@@ -37,6 +37,8 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Log in to Azure - name: Log in to Azure
uses: bitwarden/gh-actions/azure-login@main uses: bitwarden/gh-actions/azure-login@main
@@ -65,14 +67,14 @@ jobs:
- name: Add label - name: Add label
if: steps.collect.outputs.any-changed == 'true' if: steps.collect.outputs.any-changed == 'true'
run: gh pr edit $PR_NUMBER --add-label feature-flag run: gh pr edit "$PR_NUMBER" --add-label feature-flag
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
- name: Remove label - name: Remove label
if: steps.collect.outputs.any-changed == 'false' if: steps.collect.outputs.any-changed == 'false'
run: gh pr edit $PR_NUMBER --remove-label feature-flag run: gh pr edit "$PR_NUMBER" --remove-label feature-flag
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -17,5 +17,5 @@ jobs:
- name: Check for label - name: Check for label
run: | run: |
echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" echo "PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged"
echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> $GITHUB_STEP_SUMMARY echo "### :x: PRs with the hold, needs-qa or ephemeral-environment labels cannot be merged" >> "$GITHUB_STEP_SUMMARY"
exit 1 exit 1

View File

@@ -16,5 +16,5 @@ jobs:
with: with:
project: server project: server
pull_request_number: ${{ github.event.number }} pull_request_number: ${{ github.event.number }}
sync_environment: true sync_environment: false
secrets: inherit secrets: inherit

View File

@@ -63,13 +63,15 @@ jobs:
# Datadog agent for collecting OTEL metrics from k6 # Datadog agent for collecting OTEL metrics from k6
- name: Start Datadog agent - name: Start Datadog agent
env:
DD_API_KEY: ${{ steps.get-kv-secrets.outputs.DD-API-KEY }}
run: | run: |
docker run --detach \ docker run --detach \
--name datadog-agent \ --name datadog-agent \
-p 4317:4317 \ -p 4317:4317 \
-p 5555:5555 \ -p 5555:5555 \
-e DD_SITE=us3.datadoghq.com \ -e DD_SITE=us3.datadoghq.com \
-e DD_API_KEY=${{ steps.get-kv-secrets.outputs.DD-API-KEY }} \ -e DD_API_KEY="${DD_API_KEY}" \
-e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \ -e DD_DOGSTATSD_NON_LOCAL_TRAFFIC=1 \
-e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \ -e DD_OTLP_CONFIG_RECEIVER_PROTOCOLS_GRPC_ENDPOINT=0.0.0.0:4317 \
-e DD_HEALTH_PORT=5555 \ -e DD_HEALTH_PORT=5555 \

View File

@@ -34,6 +34,7 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
fetch-depth: 2 fetch-depth: 2
persist-credentials: false
- name: Check for file changes - name: Check for file changes
id: check-changes id: check-changes
@@ -43,9 +44,9 @@ jobs:
for file in $MODIFIED_FILES for file in $MODIFIED_FILES
do do
if [[ $file == *"${{ matrix.path }}"* ]]; then if [[ $file == *"${{ matrix.path }}"* ]]; then
echo "changes_detected=true" >> $GITHUB_OUTPUT echo "changes_detected=true" >> "$GITHUB_OUTPUT"
break break
else echo "changes_detected=false" >> $GITHUB_OUTPUT else echo "changes_detected=false" >> "$GITHUB_OUTPUT"
fi fi
done done

View File

@@ -36,21 +36,23 @@ jobs:
steps: steps:
- name: Version output - name: Version output
id: version-output id: version-output
env:
INPUT_VERSION: ${{ inputs.version }}
run: | run: |
if [[ "${{ inputs.version }}" == "latest" || "${{ inputs.version }}" == "" ]]; then if [[ "${INPUT_VERSION}" == "latest" || "${INPUT_VERSION}" == "" ]]; then
VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+') VERSION=$(curl "https://api.github.com/repos/bitwarden/server/releases" | jq -c '.[] | select(.tag_name) | .tag_name' | head -1 | grep -ohE '20[0-9]{2}\.([1-9]|1[0-2])\.[0-9]+')
echo "Latest Released Version: $VERSION" echo "Latest Released Version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> "$GITHUB_OUTPUT"
else else
echo "Release Version: ${{ inputs.version }}" echo "Release Version: ${INPUT_VERSION}"
echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT echo "version=${INPUT_VERSION}" >> "$GITHUB_OUTPUT"
fi fi
- 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 "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
- name: Create GitHub deployment - name: Create GitHub deployment
uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7 uses: chrnorm/deployment-action@55729fcebec3d284f60f5bcabbd8376437d696b1 # v2.0.7
@@ -105,6 +107,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Set up project name - name: Set up project name
id: setup id: setup
@@ -112,7 +117,7 @@ jobs:
PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}') PROJECT_NAME=$(echo "${{ matrix.project_name }}" | awk '{print tolower($0)}')
echo "Matrix name: ${{ matrix.project_name }}" echo "Matrix name: ${{ matrix.project_name }}"
echo "PROJECT_NAME: $PROJECT_NAME" echo "PROJECT_NAME: $PROJECT_NAME"
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT echo "project_name=$PROJECT_NAME" >> "$GITHUB_OUTPUT"
########## ACR PROD ########## ########## ACR PROD ##########
- name: Log in to Azure - name: Log in to Azure
@@ -123,16 +128,16 @@ jobs:
client_id: ${{ secrets.AZURE_CLIENT_ID }} client_id: ${{ secrets.AZURE_CLIENT_ID }}
- name: Log in to Azure ACR - name: Log in to Azure ACR
run: az acr login -n $_AZ_REGISTRY --only-show-errors run: az acr login -n "$_AZ_REGISTRY" --only-show-errors
- name: Pull latest project image - name: Pull latest project image
env: env:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker pull $_AZ_REGISTRY/$PROJECT_NAME:latest docker pull "$_AZ_REGISTRY/$PROJECT_NAME:latest"
else else
docker pull $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME docker pull "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME"
fi fi
- name: Tag version and latest - name: Tag version and latest
@@ -140,10 +145,10 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker tag $_AZ_REGISTRY/$PROJECT_NAME:latest $_AZ_REGISTRY/$PROJECT_NAME:dryrun docker tag "$_AZ_REGISTRY/$PROJECT_NAME:latest" "$_AZ_REGISTRY/$PROJECT_NAME:dryrun"
else else
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION"
docker tag $_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME $_AZ_REGISTRY/$PROJECT_NAME:latest docker tag "$_AZ_REGISTRY/$PROJECT_NAME:$_BRANCH_NAME" "$_AZ_REGISTRY/$PROJECT_NAME:latest"
fi fi
- name: Push version and latest image - name: Push version and latest image
@@ -151,10 +156,10 @@ jobs:
PROJECT_NAME: ${{ steps.setup.outputs.project_name }} PROJECT_NAME: ${{ steps.setup.outputs.project_name }}
run: | run: |
if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then if [[ "${{ inputs.publish_type }}" == "Dry Run" ]]; then
docker push $_AZ_REGISTRY/$PROJECT_NAME:dryrun docker push "$_AZ_REGISTRY/$PROJECT_NAME:dryrun"
else else
docker push $_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION docker push "$_AZ_REGISTRY/$PROJECT_NAME:$_RELEASE_VERSION"
docker push $_AZ_REGISTRY/$PROJECT_NAME:latest docker push "$_AZ_REGISTRY/$PROJECT_NAME:latest"
fi fi
- name: Log out of Docker - name: Log out of Docker

View File

@@ -40,6 +40,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Check release version - name: Check release version
id: version id: version
@@ -52,8 +55,8 @@ jobs:
- 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 "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
release: release:
name: Create GitHub release name: Create GitHub release

View File

@@ -46,7 +46,7 @@ jobs:
BRANCH="hotfix-rc" BRANCH="hotfix-rc"
fi fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
bump_version: bump_version:
name: Bump Version name: Bump Version
@@ -95,6 +95,7 @@ jobs:
with: with:
ref: main ref: main
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Configure Git - name: Configure Git
run: | run: |
@@ -110,7 +111,7 @@ jobs:
id: current-version id: current-version
run: | run: |
CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props) CURRENT_VERSION=$(xmllint -xpath "/Project/PropertyGroup/Version/text()" Directory.Build.props)
echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT echo "version=$CURRENT_VERSION" >> "$GITHUB_OUTPUT"
- name: Verify input version - name: Verify input version
if: ${{ inputs.version_number_override != '' }} if: ${{ inputs.version_number_override != '' }}
@@ -120,16 +121,15 @@ jobs:
run: | run: |
# Error if version has not changed. # Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY echo "Specified override version is the same as the current version." >> "$GITHUB_STEP_SUMMARY"
exit 1 exit 1
fi fi
# Check if version is newer. # Check if version is newer.
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then
if [ $? -eq 0 ]; then
echo "Version is newer than the current version." echo "Version is newer than the current version."
else else
echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY echo "Version is older than the current version." >> "$GITHUB_STEP_SUMMARY"
exit 1 exit 1
fi fi
@@ -160,15 +160,20 @@ jobs:
id: set-final-version-output id: set-final-version-output
env: env:
VERSION: ${{ inputs.version_number_override }} VERSION: ${{ inputs.version_number_override }}
BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }}
BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }}
CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }}
run: | run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT"
fi fi
- name: Commit files - name: Commit files
run: git commit -m "Bumped version to ${{ steps.set-final-version-output.outputs.version }}" -a env:
FINAL_VERSION: ${{ steps.set-final-version-output.outputs.version }}
run: git commit -m "Bumped version to $FINAL_VERSION" -a
- name: Push changes - name: Push changes
run: git push run: git push
@@ -213,13 +218,15 @@ jobs:
with: with:
ref: ${{ inputs.target_ref }} ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }} token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
fetch-depth: 0
- name: Check if ${{ needs.setup.outputs.branch }} branch exists - name: Check if ${{ needs.setup.outputs.branch }} branch exists
env: env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then if [[ $(git ls-remote --heads origin "$BRANCH_NAME") ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> "$GITHUB_STEP_SUMMARY"
exit 1 exit 1
fi fi
@@ -227,8 +234,8 @@ jobs:
env: env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }} BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: | run: |
git switch --quiet --create $BRANCH_NAME git switch --quiet --create "$BRANCH_NAME"
git push --quiet --set-upstream origin $BRANCH_NAME git push --quiet --set-upstream origin "$BRANCH_NAME"
move_edd_db_scripts: move_edd_db_scripts:
name: Move EDD database scripts name: Move EDD database scripts

28
.github/workflows/respond.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Respond
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
permissions: {}
jobs:
respond:
name: Respond
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions:
actions: read
contents: write
id-token: write
issues: write
pull-requests: write

View File

@@ -1,124 +1,20 @@
name: Review code name: Code Review
on: on:
pull_request: pull_request:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened, ready_for_review]
permissions: {} permissions: {}
jobs: jobs:
review: review:
name: Review name: Review
runs-on: ubuntu-24.04 uses: bitwarden/gh-actions/.github/workflows/_review-code.yml@main
secrets:
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
permissions: permissions:
contents: read contents: read
id-token: write id-token: write
pull-requests: write pull-requests: write
steps:
- name: Check out repo
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Check for Vault team changes
id: check_changes
run: |
# Ensure we have the base branch
git fetch origin ${{ github.base_ref }}
echo "Comparing changes between origin/${{ github.base_ref }} and HEAD"
CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD)
if [ -z "$CHANGED_FILES" ]; then
echo "Zero files changed"
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
# Handle variations in spacing and multiple teams
VAULT_PATTERNS=$(grep -E "@bitwarden/team-vault-dev(\s|$)" .github/CODEOWNERS 2>/dev/null | awk '{print $1}')
if [ -z "$VAULT_PATTERNS" ]; then
echo "⚠️ No patterns found for @bitwarden/team-vault-dev in CODEOWNERS"
echo "vault_team_changes=false" >> $GITHUB_OUTPUT
exit 0
fi
vault_team_changes=false
for pattern in $VAULT_PATTERNS; do
echo "Checking pattern: $pattern"
# Handle **/directory patterns
if [[ "$pattern" == "**/"* ]]; then
# Remove the **/ prefix
dir_pattern="${pattern#\*\*/}"
# Check if any file contains this directory in its path
if echo "$CHANGED_FILES" | grep -qE "(^|/)${dir_pattern}(/|$)"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep -E "(^|/)${dir_pattern}(/|$)" | sed 's/^/ - /'
break
fi
else
# Handle other patterns (shouldn't happen based on your CODEOWNERS)
if echo "$CHANGED_FILES" | grep -q "$pattern"; then
vault_team_changes=true
echo "✅ Found files matching pattern: $pattern"
echo "$CHANGED_FILES" | grep "$pattern" | sed 's/^/ - /'
break
fi
fi
done
echo "vault_team_changes=$vault_team_changes" >> $GITHUB_OUTPUT
if [ "$vault_team_changes" = "true" ]; then
echo ""
echo "✅ Vault team changes detected - proceeding with review"
else
echo ""
echo "❌ No Vault team changes detected - skipping review"
fi
- name: Review with Claude Code
if: steps.check_changes.outputs.vault_team_changes == 'true'
uses: anthropics/claude-code-action@ac1a3207f3f00b4a37e2f3a6f0935733c7c64651 # v1.0.11
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
track_progress: true
use_sticky_comment: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
AUTHOR: ${{ github.event.pull_request.user.login }}
COMMIT: ${{ github.event.pull_request.head.sha }}
Please review this pull request with a focus on:
- Code quality and best practices
- Potential bugs or issues
- Security implications
- Performance considerations
Note: The PR branch is already checked out in the current working directory.
Provide a comprehensive review including:
- Summary of changes since last review
- Critical issues found (be thorough)
- Suggested improvements (be thorough)
- Good practices observed (be concise - list only the most notable items without elaboration)
- Action items for the author
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
When reviewing subsequent commits:
- Track status of previously identified issues (fixed/unfixed/reopened)
- Identify NEW problems introduced since last review
- Note if fixes introduced new issues
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
claude_args: |
--allowedTools "mcp__github_comment__update_claude_comment,mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*)"

View File

@@ -45,6 +45,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
@@ -139,26 +141,26 @@ jobs:
- name: Print MySQL Logs - name: Print MySQL Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mysql")' run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"'
- name: Print MariaDB Logs - name: Print MariaDB Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")' run: 'docker logs "$(docker ps --quiet --filter "name=mariadb")"'
- name: Print Postgres Logs - name: Print Postgres Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=postgres")' run: 'docker logs "$(docker ps --quiet --filter "name=postgres")"'
- name: Print MSSQL Logs - name: Print MSSQL Logs
if: failure() if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mssql")' run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results - name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0 uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with: with:
name: Test Results name: Test Results
path: "**/*-test-results.trx" path: "./**/*-test-results.trx"
reporter: dotnet-trx reporter: dotnet-trx
fail-on-error: true fail-on-error: true
@@ -177,6 +179,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0

View File

@@ -28,6 +28,8 @@ jobs:
steps: steps:
- name: Check out repo - name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET - name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0

2
.gitignore vendored
View File

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

View File

@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>2025.10.1</Version> <Version>2025.11.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace> <RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>

View File

@@ -140,6 +140,7 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi", "util\SeederApi\SeederApi.csproj", "{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SeederApi.IntegrationTest", "test\SeederApi.IntegrationTest\SeederApi.IntegrationTest.csproj", "{A2E067EF-609C-4D13-895A-E054C61D48BB}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -361,6 +362,10 @@ Global
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU {A2E067EF-609C-4D13-895A-E054C61D48BB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU {A2E067EF-609C-4D13-895A-E054C61D48BB}.Release|Any CPU.Build.0 = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7D98784C-C253-43FB-9873-25B65C6250D6}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@@ -419,6 +424,7 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E} {9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F} {A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F} SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

@@ -57,6 +57,7 @@ public class AccountController : Controller
private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector; private readonly IDataProtectorTokenFactory<SsoTokenable> _dataProtector;
private readonly IOrganizationDomainRepository _organizationDomainRepository; private readonly IOrganizationDomainRepository _organizationDomainRepository;
private readonly IRegisterUserCommand _registerUserCommand; private readonly IRegisterUserCommand _registerUserCommand;
private readonly IFeatureService _featureService;
public AccountController( public AccountController(
IAuthenticationSchemeProvider schemeProvider, IAuthenticationSchemeProvider schemeProvider,
@@ -77,7 +78,8 @@ public class AccountController : Controller
Core.Services.IEventService eventService, Core.Services.IEventService eventService,
IDataProtectorTokenFactory<SsoTokenable> dataProtector, IDataProtectorTokenFactory<SsoTokenable> dataProtector,
IOrganizationDomainRepository organizationDomainRepository, IOrganizationDomainRepository organizationDomainRepository,
IRegisterUserCommand registerUserCommand) IRegisterUserCommand registerUserCommand,
IFeatureService featureService)
{ {
_schemeProvider = schemeProvider; _schemeProvider = schemeProvider;
_clientStore = clientStore; _clientStore = clientStore;
@@ -98,10 +100,11 @@ public class AccountController : Controller
_dataProtector = dataProtector; _dataProtector = dataProtector;
_organizationDomainRepository = organizationDomainRepository; _organizationDomainRepository = organizationDomainRepository;
_registerUserCommand = registerUserCommand; _registerUserCommand = registerUserCommand;
_featureService = featureService;
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> PreValidate(string domainHint) public async Task<IActionResult> PreValidateAsync(string domainHint)
{ {
try try
{ {
@@ -160,7 +163,7 @@ public class AccountController : Controller
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Login(string returnUrl) public async Task<IActionResult> LoginAsync(string returnUrl)
{ {
var context = await _interaction.GetAuthorizationContextAsync(returnUrl); var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
@@ -235,37 +238,69 @@ public class AccountController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> ExternalCallback() public async Task<IActionResult> ExternalCallback()
{ {
// Feature flag (PM-24579): Prevent SSO on existing non-compliant users.
var preventOrgUserLoginIfStatusInvalid =
_featureService.IsEnabled(FeatureFlagKeys.PM24579_PreventSsoOnExistingNonCompliantUsers);
// Read external identity from the temporary cookie // Read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync( var result = await HttpContext.AuthenticateAsync(
AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme); AuthenticationSchemes.BitwardenExternalCookieAuthenticationScheme);
if (result?.Succeeded != true)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
// Debugging if (preventOrgUserLoginIfStatusInvalid)
var externalClaims = result.Principal.Claims.Select(c => $"{c.Type}: {c.Value}"); {
_logger.LogDebug("External claims: {@claims}", externalClaims); if (!result.Succeeded)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
}
else
{
if (result?.Succeeded != true)
{
throw new Exception(_i18nService.T("ExternalAuthenticationError"));
}
}
// See if the user has logged in with this SSO provider before and has already been provisioned. // See if the user has logged in with this SSO provider before and has already been provisioned.
// This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using. // This is signified by the user existing in the User table and the SSOUser table for the SSO provider they're using.
var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result); var (user, provider, providerUserId, claims, ssoConfigData) = await FindUserFromExternalProviderAsync(result);
// We will look these up as required (lazy resolution) to avoid multiple DB hits.
Organization organization = null;
OrganizationUser orgUser = null;
// The user has not authenticated with this SSO provider before. // The user has not authenticated with this SSO provider before.
// They could have an existing Bitwarden account in the User table though. // They could have an existing Bitwarden account in the User table though.
if (user == null) if (user == null)
{ {
// If we're manually linking to SSO, the user's external identifier will be passed as query string parameter. // If we're manually linking to SSO, the user's external identifier will be passed as query string parameter.
var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier") ? var userIdentifier = result.Properties.Items.Keys.Contains("user_identifier")
result.Properties.Items["user_identifier"] : null; ? result.Properties.Items["user_identifier"]
user = await AutoProvisionUserAsync(provider, providerUserId, claims, userIdentifier, ssoConfigData); : null;
var (provisionedUser, foundOrganization, foundOrCreatedOrgUser) =
await AutoProvisionUserAsync(
provider,
providerUserId,
claims,
userIdentifier,
ssoConfigData);
user = provisionedUser;
if (preventOrgUserLoginIfStatusInvalid)
{
organization = foundOrganization;
orgUser = foundOrCreatedOrgUser;
}
} }
// Either the user already authenticated with the SSO provider, or we've just provisioned them. if (preventOrgUserLoginIfStatusInvalid)
// Either way, we have associated the SSO login with a Bitwarden user.
// We will now sign the Bitwarden user in.
if (user != null)
{ {
if (user == null) throw new Exception(_i18nService.T("UserShouldBeFound"));
await PreventOrgUserLoginIfStatusInvalidAsync(organization, provider, orgUser, user);
// This allows us to collect any additional claims or properties // This allows us to collect any additional claims or properties
// for the specific protocols used and store them in the local auth cookie. // for the specific protocols used and store them in the local auth cookie.
// this is typically used to store data needed for signout from those protocols. // this is typically used to store data needed for signout from those protocols.
@@ -278,12 +313,41 @@ public class AccountController : Controller
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps); ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user // Issue authentication cookie for user
await HttpContext.SignInAsync(new IdentityServerUser(user.Id.ToString()) await HttpContext.SignInAsync(
new IdentityServerUser(user.Id.ToString())
{
DisplayName = user.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
else
{
// Either the user already authenticated with the SSO provider, or we've just provisioned them.
// Either way, we have associated the SSO login with a Bitwarden user.
// We will now sign the Bitwarden user in.
if (user != null)
{ {
DisplayName = user.Email, // This allows us to collect any additional claims or properties
IdentityProvider = provider, // for the specific protocols used and store them in the local auth cookie.
AdditionalClaims = additionalLocalClaims.ToArray() // this is typically used to store data needed for signout from those protocols.
}, localSignInProps); var additionalLocalClaims = new List<Claim>();
var localSignInProps = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(1)
};
ProcessLoginCallback(result, additionalLocalClaims, localSignInProps);
// Issue authentication cookie for user
await HttpContext.SignInAsync(
new IdentityServerUser(user.Id.ToString())
{
DisplayName = user.Email,
IdentityProvider = provider,
AdditionalClaims = additionalLocalClaims.ToArray()
}, localSignInProps);
}
} }
// Delete temporary cookie used during external authentication // Delete temporary cookie used during external authentication
@@ -310,7 +374,7 @@ public class AccountController : Controller
} }
[HttpGet] [HttpGet]
public async Task<IActionResult> Logout(string logoutId) public async Task<IActionResult> LogoutAsync(string logoutId)
{ {
// Build a model so the logged out page knows what to display // Build a model so the logged out page knows what to display
var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId); var (updatedLogoutId, redirectUri, externalAuthenticationScheme) = await GetLoggedOutDataAsync(logoutId);
@@ -333,6 +397,7 @@ public class AccountController : Controller
// This triggers a redirect to the external provider for sign-out // This triggers a redirect to the external provider for sign-out
return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme); return SignOut(new AuthenticationProperties { RedirectUri = url }, externalAuthenticationScheme);
} }
if (redirectUri != null) if (redirectUri != null)
{ {
return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri }); return View("Redirect", new RedirectViewModel { RedirectUrl = redirectUri });
@@ -347,7 +412,8 @@ public class AccountController : Controller
/// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`. /// Attempts to map the external identity to a Bitwarden user, through the SsoUser table, which holds the `externalId`.
/// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records. /// The claims on the external identity are used to determine an `externalId`, and that is used to find the appropriate `SsoUser` and `User` records.
/// </summary> /// </summary>
private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims, SsoConfigurationData config)> private async Task<(User user, string provider, string providerUserId, IEnumerable<Claim> claims,
SsoConfigurationData config)>
FindUserFromExternalProviderAsync(AuthenticateResult result) FindUserFromExternalProviderAsync(AuthenticateResult result)
{ {
var provider = result.Properties.Items["scheme"]; var provider = result.Properties.Items["scheme"];
@@ -374,9 +440,10 @@ public class AccountController : Controller
// Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute // Ensure the NameIdentifier used is not a transient name ID, if so, we need a different attribute
// for the user identifier. // for the user identifier.
static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier static bool nameIdIsNotTransient(Claim c) => c.Type == ClaimTypes.NameIdentifier
&& (c.Properties == null && (c.Properties == null
|| !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat, out var claimFormat) || !c.Properties.TryGetValue(SamlPropertyKeys.ClaimFormat,
|| claimFormat != SamlNameIdFormats.Transient); out var claimFormat)
|| claimFormat != SamlNameIdFormats.Transient);
// Try to determine the unique id of the external user (issued by the provider) // Try to determine the unique id of the external user (issued by the provider)
// the most common claim type for that are the sub claim and the NameIdentifier // the most common claim type for that are the sub claim and the NameIdentifier
@@ -418,24 +485,20 @@ public class AccountController : Controller
/// <param name="providerUserId">The external identity provider's user identifier.</param> /// <param name="providerUserId">The external identity provider's user identifier.</param>
/// <param name="claims">The claims from the external IdP.</param> /// <param name="claims">The claims from the external IdP.</param>
/// <param name="userIdentifier">The user identifier used for manual SSO linking.</param> /// <param name="userIdentifier">The user identifier used for manual SSO linking.</param>
/// <param name="config">The SSO configuration for the organization.</param> /// <param name="ssoConfigData">The SSO configuration for the organization.</param>
/// <returns>The User to sign in.</returns> /// <returns>Guaranteed to return the user to sign in as well as the found organization and org user.</returns>
/// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception> /// <exception cref="Exception">An exception if the user cannot be provisioned as requested.</exception>
private async Task<User> AutoProvisionUserAsync(string provider, string providerUserId, private async Task<(User user, Organization foundOrganization, OrganizationUser foundOrgUser)>
IEnumerable<Claim> claims, string userIdentifier, SsoConfigurationData config) AutoProvisionUserAsync(
string provider,
string providerUserId,
IEnumerable<Claim> claims,
string userIdentifier,
SsoConfigurationData ssoConfigData
)
{ {
var name = GetName(claims, config.GetAdditionalNameClaimTypes()); var name = GetName(claims, ssoConfigData.GetAdditionalNameClaimTypes());
var email = GetEmailAddress(claims, config.GetAdditionalEmailClaimTypes()); var email = TryGetEmailAddress(claims, ssoConfigData, providerUserId);
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
{
email = providerUserId;
}
if (!Guid.TryParse(provider, out var orgId))
{
// TODO: support non-org (server-wide) SSO in the future?
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
}
User existingUser = null; User existingUser = null;
if (string.IsNullOrWhiteSpace(userIdentifier)) if (string.IsNullOrWhiteSpace(userIdentifier))
@@ -444,15 +507,19 @@ public class AccountController : Controller
{ {
throw new Exception(_i18nService.T("CannotFindEmailClaim")); throw new Exception(_i18nService.T("CannotFindEmailClaim"));
} }
existingUser = await _userRepository.GetByEmailAsync(email); existingUser = await _userRepository.GetByEmailAsync(email);
} }
else else
{ {
existingUser = await GetUserFromManualLinkingData(userIdentifier); existingUser = await GetUserFromManualLinkingDataAsync(userIdentifier);
} }
// Try to find the OrganizationUser if it exists. // Try to find the org (we error if we can't find an org)
var (organization, orgUser) = await FindOrganizationUser(existingUser, email, orgId); var organization = await TryGetOrganizationByProviderAsync(provider);
// Try to find an org user (null org user possible and valid here)
var orgUser = await TryGetOrganizationUserByUserAndOrgOrEmail(existingUser, organization.Id, email);
//---------------------------------------------------- //----------------------------------------------------
// Scenario 1: We've found the user in the User table // Scenario 1: We've found the user in the User table
@@ -473,22 +540,22 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess")); throw new Exception(_i18nService.T("UserAlreadyExistsInviteProcess"));
} }
EnsureOrgUserStatusAllowed(orgUser.Status, organization.DisplayName(), EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
allowedStatuses: [OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed]);
// Since we're in the auto-provisioning logic, this means that the user exists, but they have not // Since we're in the auto-provisioning logic, this means that the user exists, but they have not
// authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them). // authenticated with the org's SSO provider before now (otherwise we wouldn't be auto-provisioning them).
// We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed // We've verified that the user is Accepted or Confnirmed, so we can create an SsoUser link and proceed
// with authentication. // with authentication.
await CreateSsoUserRecord(providerUserId, existingUser.Id, orgId, orgUser); await CreateSsoUserRecordAsync(providerUserId, existingUser.Id, organization.Id, orgUser);
return existingUser;
return (existingUser, organization, orgUser);
} }
// Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one // Before any user creation - if Org User doesn't exist at this point - make sure there are enough seats to add one
if (orgUser == null && organization.Seats.HasValue) if (orgUser == null && organization.Seats.HasValue)
{ {
var occupiedSeats = await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id); var occupiedSeats =
await _organizationRepository.GetOccupiedSeatCountByOrganizationIdAsync(organization.Id);
var initialSeatCount = organization.Seats.Value; var initialSeatCount = organization.Seats.Value;
var availableSeats = initialSeatCount - occupiedSeats.Total; var availableSeats = initialSeatCount - occupiedSeats.Total;
if (availableSeats < 1) if (availableSeats < 1)
@@ -506,8 +573,10 @@ public class AccountController : Controller
{ {
if (organization.Seats.Value != initialSeatCount) if (organization.Seats.Value != initialSeatCount)
{ {
await _organizationService.AdjustSeatsAsync(orgId, initialSeatCount - organization.Seats.Value); await _organizationService.AdjustSeatsAsync(organization.Id,
initialSeatCount - organization.Seats.Value);
} }
_logger.LogInformation(e, "SSO auto provisioning failed"); _logger.LogInformation(e, "SSO auto provisioning failed");
throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName())); throw new Exception(_i18nService.T("NoSeatsAvailable", organization.DisplayName()));
} }
@@ -519,7 +588,8 @@ public class AccountController : Controller
var emailDomain = CoreHelpers.GetEmailDomain(email); var emailDomain = CoreHelpers.GetEmailDomain(email);
if (!string.IsNullOrWhiteSpace(emailDomain)) if (!string.IsNullOrWhiteSpace(emailDomain))
{ {
var organizationDomain = await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(orgId, emailDomain); var organizationDomain =
await _organizationDomainRepository.GetDomainByOrgIdAndDomainNameAsync(organization.Id, emailDomain);
emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false; emailVerified = organizationDomain?.VerifiedDate.HasValue ?? false;
} }
@@ -537,7 +607,7 @@ public class AccountController : Controller
// If the organization has 2fa policy enabled, make sure to default jit user 2fa to email // If the organization has 2fa policy enabled, make sure to default jit user 2fa to email
var twoFactorPolicy = var twoFactorPolicy =
await _policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.TwoFactorAuthentication); await _policyRepository.GetByOrganizationIdTypeAsync(organization.Id, PolicyType.TwoFactorAuthentication);
if (twoFactorPolicy != null && twoFactorPolicy.Enabled) if (twoFactorPolicy != null && twoFactorPolicy.Enabled)
{ {
user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider> user.SetTwoFactorProviders(new Dictionary<TwoFactorProviderType, TwoFactorProvider>
@@ -560,13 +630,14 @@ public class AccountController : Controller
{ {
orgUser = new OrganizationUser orgUser = new OrganizationUser
{ {
OrganizationId = orgId, OrganizationId = organization.Id,
UserId = user.Id, UserId = user.Id,
Type = OrganizationUserType.User, Type = OrganizationUserType.User,
Status = OrganizationUserStatusType.Invited Status = OrganizationUserStatusType.Invited
}; };
await _organizationUserRepository.CreateAsync(orgUser); await _organizationUserRepository.CreateAsync(orgUser);
} }
//----------------------------------------------------------------- //-----------------------------------------------------------------
// Scenario 3: There is already an existing OrganizationUser // Scenario 3: There is already an existing OrganizationUser
// That was established through an invitation. We just need to // That was established through an invitation. We just need to
@@ -579,12 +650,47 @@ public class AccountController : Controller
} }
// Create the SsoUser record to link the user to the SSO provider. // Create the SsoUser record to link the user to the SSO provider.
await CreateSsoUserRecord(providerUserId, user.Id, orgId, orgUser); await CreateSsoUserRecordAsync(providerUserId, user.Id, organization.Id, orgUser);
return user; return (user, organization, orgUser);
} }
private async Task<User> GetUserFromManualLinkingData(string userIdentifier) /// <summary>
/// Validates an organization user is allowed to log in via SSO and blocks invalid statuses.
/// Lazily resolves the organization and organization user if not provided.
/// </summary>
/// <param name="organization">The target organization; if null, resolved from provider.</param>
/// <param name="provider">The SSO scheme provider value (organization id as a GUID string).</param>
/// <param name="orgUser">The organization-user record; if null, looked up by user/org or user email for invited users.</param>
/// <param name="user">The user attempting to sign in (existing or newly provisioned).</param>
/// <exception cref="Exception">Thrown if the organization cannot be resolved from provider;
/// the organization user cannot be found; or the organization user status is not allowed.</exception>
private async Task PreventOrgUserLoginIfStatusInvalidAsync(
Organization organization,
string provider,
OrganizationUser orgUser,
User user)
{
// Lazily get organization if not already known
organization ??= await TryGetOrganizationByProviderAsync(provider);
// Lazily get the org user if not already known
orgUser ??= await TryGetOrganizationUserByUserAndOrgOrEmail(
user,
organization.Id,
user.Email);
if (orgUser != null)
{
EnsureAcceptedOrConfirmedOrgUserStatus(orgUser.Status, organization.DisplayName());
}
else
{
throw new Exception(_i18nService.T("CouldNotFindOrganizationUser", user.Id, organization.Id));
}
}
private async Task<User> GetUserFromManualLinkingDataAsync(string userIdentifier)
{ {
User user = null; User user = null;
var split = userIdentifier.Split(","); var split = userIdentifier.Split(",");
@@ -592,6 +698,7 @@ public class AccountController : Controller
{ {
throw new Exception(_i18nService.T("InvalidUserIdentifier")); throw new Exception(_i18nService.T("InvalidUserIdentifier"));
} }
var userId = split[0]; var userId = split[0];
var token = split[1]; var token = split[1];
@@ -611,38 +718,73 @@ public class AccountController : Controller
throw new Exception(_i18nService.T("UserIdAndTokenMismatch")); throw new Exception(_i18nService.T("UserIdAndTokenMismatch"));
} }
} }
return user; return user;
} }
private async Task<(Organization, OrganizationUser)> FindOrganizationUser(User existingUser, string email, Guid orgId) /// <summary>
/// Tries to get the organization by the provider which is org id for us as we use the scheme
/// to identify organizations - not identity providers.
/// </summary>
/// <param name="provider">Org id string from SSO scheme property</param>
/// <exception cref="Exception">Errors if the provider string is not a valid org id guid or if the org cannot be found by the id.</exception>
private async Task<Organization> TryGetOrganizationByProviderAsync(string provider)
{ {
OrganizationUser orgUser = null; if (!Guid.TryParse(provider, out var organizationId))
var organization = await _organizationRepository.GetByIdAsync(orgId); {
// TODO: support non-org (server-wide) SSO in the future?
throw new Exception(_i18nService.T("SSOProviderIsNotAnOrgId", provider));
}
var organization = await _organizationRepository.GetByIdAsync(organizationId);
if (organization == null) if (organization == null)
{ {
throw new Exception(_i18nService.T("CouldNotFindOrganization", orgId)); throw new Exception(_i18nService.T("CouldNotFindOrganization", organizationId));
} }
return organization;
}
/// <summary>
/// Attempts to get an <see cref="OrganizationUser"/> for a given organization
/// by first checking for an existing user relationship, and if none is found,
/// by looking up an invited user via their email address.
/// </summary>
/// <param name="user">The existing user entity to be looked up in OrganizationUsers table.</param>
/// <param name="organizationId">Organization id from the provider data.</param>
/// <param name="email">Email to use as a fallback in case of an invited user not in the Org Users
/// table yet.</param>
private async Task<OrganizationUser> TryGetOrganizationUserByUserAndOrgOrEmail(
User user,
Guid organizationId,
string email)
{
OrganizationUser orgUser = null;
// Try to find OrgUser via existing User Id. // Try to find OrgUser via existing User Id.
// This covers any OrganizationUser state after they have accepted an invite. // This covers any OrganizationUser state after they have accepted an invite.
if (existingUser != null) if (user != null)
{ {
var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(existingUser.Id); var orgUsersByUserId = await _organizationUserRepository.GetManyByUserAsync(user.Id);
orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == orgId); orgUser = orgUsersByUserId.SingleOrDefault(u => u.OrganizationId == organizationId);
} }
// If no Org User found by Existing User Id - search all the organization's users via email. // If no Org User found by Existing User Id - search all the organization's users via email.
// This covers users who are Invited but haven't accepted their invite yet. // This covers users who are Invited but haven't accepted their invite yet.
orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(orgId, email); orgUser ??= await _organizationUserRepository.GetByOrganizationEmailAsync(organizationId, email);
return (organization, orgUser); return orgUser;
} }
private void EnsureOrgUserStatusAllowed( private void EnsureAcceptedOrConfirmedOrgUserStatus(
OrganizationUserStatusType status, OrganizationUserStatusType status,
string organizationDisplayName, string organizationDisplayName)
params OrganizationUserStatusType[] allowedStatuses)
{ {
// The only permissible org user statuses allowed.
OrganizationUserStatusType[] allowedStatuses =
[OrganizationUserStatusType.Accepted, OrganizationUserStatusType.Confirmed];
// if this status is one of the allowed ones, just return // if this status is one of the allowed ones, just return
if (allowedStatuses.Contains(status)) if (allowedStatuses.Contains(status))
{ {
@@ -667,7 +809,6 @@ public class AccountController : Controller
} }
} }
private IActionResult InvalidJson(string errorMessageKey, Exception ex = null) private IActionResult InvalidJson(string errorMessageKey, Exception ex = null)
{ {
Response.StatusCode = ex == null ? 400 : 500; Response.StatusCode = ex == null ? 400 : 500;
@@ -679,13 +820,13 @@ public class AccountController : Controller
}); });
} }
private string GetEmailAddress(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes) private string TryGetEmailAddressFromClaims(IEnumerable<Claim> claims, IEnumerable<string> additionalClaimTypes)
{ {
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@")); var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value) && c.Value.Contains("@"));
var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ?? var email = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email, filteredClaims.GetFirstMatch(JwtClaimTypes.Email, ClaimTypes.Email,
SamlClaimTypes.Email, "mail", "emailaddress"); SamlClaimTypes.Email, "mail", "emailaddress");
if (!string.IsNullOrWhiteSpace(email)) if (!string.IsNullOrWhiteSpace(email))
{ {
return email; return email;
@@ -706,8 +847,8 @@ public class AccountController : Controller
var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value)); var filteredClaims = claims.Where(c => !string.IsNullOrWhiteSpace(c.Value));
var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ?? var name = filteredClaims.GetFirstMatch(additionalClaimTypes.ToArray()) ??
filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name, filteredClaims.GetFirstMatch(JwtClaimTypes.Name, ClaimTypes.Name,
SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn"); SamlClaimTypes.DisplayName, SamlClaimTypes.CommonName, "displayname", "cn");
if (!string.IsNullOrWhiteSpace(name)) if (!string.IsNullOrWhiteSpace(name))
{ {
return name; return name;
@@ -725,7 +866,8 @@ public class AccountController : Controller
return null; return null;
} }
private async Task CreateSsoUserRecord(string providerUserId, Guid userId, Guid orgId, OrganizationUser orgUser) private async Task CreateSsoUserRecordAsync(string providerUserId, Guid userId, Guid orgId,
OrganizationUser orgUser)
{ {
// Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale // Delete existing SsoUser (if any) - avoids error if providerId has changed and the sso link is stale
var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId); var existingSsoUser = await _ssoUserRepository.GetByUserIdOrganizationIdAsync(orgId, userId);
@@ -740,12 +882,7 @@ public class AccountController : Controller
await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin); await _eventService.LogOrganizationUserEventAsync(orgUser, EventType.OrganizationUser_FirstSsoLogin);
} }
var ssoUser = new SsoUser var ssoUser = new SsoUser { ExternalId = providerUserId, UserId = userId, OrganizationId = orgId, };
{
ExternalId = providerUserId,
UserId = userId,
OrganizationId = orgId,
};
await _ssoUserRepository.CreateAsync(ssoUser); await _ssoUserRepository.CreateAsync(ssoUser);
} }
@@ -769,18 +906,6 @@ public class AccountController : Controller
} }
} }
private async Task<string> GetProviderAsync(string returnUrl)
{
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context?.IdP != null && await _schemeProvider.GetSchemeAsync(context.IdP) != null)
{
return context.IdP;
}
var schemes = await _schemeProvider.GetAllSchemesAsync();
var providers = schemes.Select(x => x.Name).ToList();
return providers.FirstOrDefault();
}
private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId) private async Task<(string, string, string)> GetLoggedOutDataAsync(string logoutId)
{ {
// Get context information (client name, post logout redirect URI and iframe for federated signout) // Get context information (client name, post logout redirect URI and iframe for federated signout)
@@ -812,9 +937,29 @@ public class AccountController : Controller
return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme); return (logoutId, logout?.PostLogoutRedirectUri, externalAuthenticationScheme);
} }
/**
* Tries to get a user's email from the claims and SSO configuration data or the provider user id if
* the claims email extraction returns null.
*/
private string TryGetEmailAddress(
IEnumerable<Claim> claims,
SsoConfigurationData config,
string providerUserId)
{
var email = TryGetEmailAddressFromClaims(claims, config.GetAdditionalEmailClaimTypes());
// If email isn't populated from claims and providerUserId has @, assume it is the email.
if (string.IsNullOrWhiteSpace(email) && providerUserId.Contains("@"))
{
email = providerUserId;
}
return email;
}
public bool IsNativeClient(DIM.AuthorizationRequest context) public bool IsNativeClient(DIM.AuthorizationRequest context)
{ {
return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal) return !context.RedirectUri.StartsWith("https", StringComparison.Ordinal)
&& !context.RedirectUri.StartsWith("http", StringComparison.Ordinal); && !context.RedirectUri.StartsWith("http", StringComparison.Ordinal);
} }
} }

View File

@@ -17,9 +17,9 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.101.3", "webpack": "5.102.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
} }
}, },
@@ -678,6 +678,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -704,6 +705,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -746,6 +748,16 @@
"ajv": "^8.8.2" "ajv": "^8.8.2"
} }
}, },
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.6", "version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
@@ -780,9 +792,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.4", "version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -799,10 +811,12 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "baseline-browser-mapping": "^2.8.9",
"electron-to-chromium": "^1.5.211", "caniuse-lite": "^1.0.30001746",
"node-releases": "^2.0.19", "electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.3"
}, },
"bin": { "bin": {
@@ -820,9 +834,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001741", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -974,9 +988,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.215", "version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -1527,9 +1541,9 @@
"optional": true "optional": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.20", "version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1653,6 +1667,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1859,11 +1874,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.91.0", "version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.0.2", "immutable": "^5.0.2",
@@ -1921,9 +1937,9 @@
} }
}, },
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "4.3.2", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2060,9 +2076,9 @@
} }
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.3", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2201,11 +2217,12 @@
} }
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.101.3", "version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -2215,7 +2232,7 @@
"@webassemblyjs/wasm-parser": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0", "acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3", "acorn-import-phases": "^1.0.3",
"browserslist": "^4.24.0", "browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2", "chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3", "enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1", "es-module-lexer": "^1.2.1",
@@ -2227,10 +2244,10 @@
"loader-runner": "^4.2.0", "loader-runner": "^4.2.0",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"schema-utils": "^4.3.2", "schema-utils": "^4.3.3",
"tapable": "^2.1.1", "tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11", "terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1", "watchpack": "^2.4.4",
"webpack-sources": "^3.3.3" "webpack-sources": "^3.3.3"
}, },
"bin": { "bin": {
@@ -2255,6 +2272,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.5.0", "@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1", "@webpack-cli/configtest": "^2.1.1",

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="$(MicrosoftNetTestSdkVersion)" />
<PackageReference Include="xunit" Version="$(XUnitVersion)" />
<PackageReference Include="xunit.runner.visualstudio" Version="$(XUnitRunnerVisualStudioVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="$(CoverletCollectorVersion)">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="NSubstitute" Version="$(NSubstituteVersion)" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Sso\Sso.csproj" />
<ProjectReference Include="..\..\..\test\Common\Common.csproj" />
</ItemGroup>
</Project>

View File

@@ -152,13 +152,10 @@
<input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'> <input type="checkbox" class="form-check-input" asp-for="UseCustomPermissions" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseCustomPermissions"></label> <label class="form-check-label" asp-for="UseCustomPermissions"></label>
</div> </div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.PM17772_AdminInitiatedSponsorships)) <div class="form-check">
{ <input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<div class="form-check"> <label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'> </div>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
}
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers)) @if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{ {
<div class="form-check"> <div class="form-check">

View File

@@ -18,9 +18,9 @@
"css-loader": "7.1.2", "css-loader": "7.1.2",
"expose-loader": "5.0.1", "expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2", "mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0", "sass": "1.93.2",
"sass-loader": "16.0.5", "sass-loader": "16.0.5",
"webpack": "5.101.3", "webpack": "5.102.1",
"webpack-cli": "5.1.4" "webpack-cli": "5.1.4"
} }
}, },
@@ -679,6 +679,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -705,6 +706,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -747,6 +749,16 @@
"ajv": "^8.8.2" "ajv": "^8.8.2"
} }
}, },
"node_modules/baseline-browser-mapping": {
"version": "2.8.18",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz",
"integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
}
},
"node_modules/bootstrap": { "node_modules/bootstrap": {
"version": "5.3.6", "version": "5.3.6",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.6.tgz",
@@ -781,9 +793,9 @@
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.25.4", "version": "4.26.3",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz",
"integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -800,10 +812,12 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001737", "baseline-browser-mapping": "^2.8.9",
"electron-to-chromium": "^1.5.211", "caniuse-lite": "^1.0.30001746",
"node-releases": "^2.0.19", "electron-to-chromium": "^1.5.227",
"node-releases": "^2.0.21",
"update-browserslist-db": "^1.1.3" "update-browserslist-db": "^1.1.3"
}, },
"bin": { "bin": {
@@ -821,9 +835,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001741", "version": "1.0.30001751",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz",
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -975,9 +989,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.215", "version": "1.5.237",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz",
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==", "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -1528,9 +1542,9 @@
"optional": true "optional": true
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.20", "version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.20.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
"integrity": "sha512-7gK6zSXEH6neM212JgfYFXe+GmZQM+fia5SsusuBIUgnPheLFBmIPhtFoAQRj8/7wASYQnbDlHPVwY0BefoFgA==", "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -1654,6 +1668,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -1860,11 +1875,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sass": { "node_modules/sass": {
"version": "1.91.0", "version": "1.93.2",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", "resolved": "https://registry.npmjs.org/sass/-/sass-1.93.2.tgz",
"integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "integrity": "sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"immutable": "^5.0.2", "immutable": "^5.0.2",
@@ -1922,9 +1938,9 @@
} }
}, },
"node_modules/schema-utils": { "node_modules/schema-utils": {
"version": "4.3.2", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.2.tgz", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
"integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2061,9 +2077,9 @@
} }
}, },
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.3", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.3.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",
"integrity": "sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -2210,11 +2226,12 @@
} }
}, },
"node_modules/webpack": { "node_modules/webpack": {
"version": "5.101.3", "version": "5.102.1",
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.102.1.tgz",
"integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "integrity": "sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/eslint-scope": "^3.7.7", "@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8", "@types/estree": "^1.0.8",
@@ -2224,7 +2241,7 @@
"@webassemblyjs/wasm-parser": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1",
"acorn": "^8.15.0", "acorn": "^8.15.0",
"acorn-import-phases": "^1.0.3", "acorn-import-phases": "^1.0.3",
"browserslist": "^4.24.0", "browserslist": "^4.26.3",
"chrome-trace-event": "^1.0.2", "chrome-trace-event": "^1.0.2",
"enhanced-resolve": "^5.17.3", "enhanced-resolve": "^5.17.3",
"es-module-lexer": "^1.2.1", "es-module-lexer": "^1.2.1",
@@ -2236,10 +2253,10 @@
"loader-runner": "^4.2.0", "loader-runner": "^4.2.0",
"mime-types": "^2.1.27", "mime-types": "^2.1.27",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"schema-utils": "^4.3.2", "schema-utils": "^4.3.3",
"tapable": "^2.1.1", "tapable": "^2.3.0",
"terser-webpack-plugin": "^5.3.11", "terser-webpack-plugin": "^5.3.11",
"watchpack": "^2.4.1", "watchpack": "^2.4.4",
"webpack-sources": "^3.3.3" "webpack-sources": "^3.3.3"
}, },
"bin": { "bin": {
@@ -2264,6 +2281,7 @@
"integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@discoveryjs/json-ext": "^0.5.0", "@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^2.1.1", "@webpack-cli/configtest": "^2.1.1",

View File

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

View File

@@ -12,10 +12,11 @@ public static class AuthorizationHandlerCollectionExtensions
services.TryAddScoped<IOrganizationContext, OrganizationContext>(); services.TryAddScoped<IOrganizationContext, OrganizationContext>();
services.TryAddEnumerable([ services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(), ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(), ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(), ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(), ServiceDescriptor.Scoped<IAuthorizationHandler, OrganizationRequirementHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, RecoverAccountAuthorizationHandler>(),
]); ]);
} }
} }

View File

@@ -0,0 +1,110 @@
using System.Security.Claims;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Microsoft.AspNetCore.Authorization;
namespace Bit.Api.AdminConsole.Authorization;
/// <summary>
/// An authorization requirement for recovering an organization member's account.
/// </summary>
/// <remarks>
/// Note: this is different to simply being able to manage account recovery. The user must be recovering
/// a member who has equal or lesser permissions than them.
/// </remarks>
public class RecoverAccountAuthorizationRequirement : IAuthorizationRequirement;
/// <summary>
/// Authorizes members and providers to recover a target OrganizationUser's account.
/// </summary>
/// <remarks>
/// This prevents privilege escalation by ensuring that a user cannot recover the account of
/// another user with a higher role or with provider membership.
/// </remarks>
public class RecoverAccountAuthorizationHandler(
IOrganizationContext organizationContext,
ICurrentContext currentContext,
IProviderUserRepository providerUserRepository)
: AuthorizationHandler<RecoverAccountAuthorizationRequirement, OrganizationUser>
{
public const string FailureReason = "You are not permitted to recover this user's account.";
public const string ProviderFailureReason = "You are not permitted to recover a Provider member's account.";
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
RecoverAccountAuthorizationRequirement requirement,
OrganizationUser targetOrganizationUser)
{
// Step 1: check that the User has permissions with respect to the organization.
// This may come from their role in the organization or their provider relationship.
var canRecoverOrganizationMember =
AuthorizeMember(context.User, targetOrganizationUser) ||
await AuthorizeProviderAsync(context.User, targetOrganizationUser);
if (!canRecoverOrganizationMember)
{
context.Fail(new AuthorizationFailureReason(this, FailureReason));
return;
}
// Step 2: check that the User has permissions with respect to any provider the target user is a member of.
// This prevents an organization admin performing privilege escalation into an unrelated provider.
var canRecoverProviderMember = await CanRecoverProviderAsync(targetOrganizationUser);
if (!canRecoverProviderMember)
{
context.Fail(new AuthorizationFailureReason(this, ProviderFailureReason));
return;
}
context.Succeed(requirement);
}
private async Task<bool> AuthorizeProviderAsync(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
return await organizationContext.IsProviderUserForOrganization(currentUser, targetOrganizationUser.OrganizationId);
}
private bool AuthorizeMember(ClaimsPrincipal currentUser, OrganizationUser targetOrganizationUser)
{
var currentContextOrganization = organizationContext.GetOrganizationClaims(currentUser, targetOrganizationUser.OrganizationId);
if (currentContextOrganization == null)
{
return false;
}
// Current user must have equal or greater permissions than the user account being recovered
var authorized = targetOrganizationUser.Type switch
{
OrganizationUserType.Owner => currentContextOrganization.Type is OrganizationUserType.Owner,
OrganizationUserType.Admin => currentContextOrganization.Type is OrganizationUserType.Owner or OrganizationUserType.Admin,
_ => currentContextOrganization is
{ Type: OrganizationUserType.Owner or OrganizationUserType.Admin }
or { Type: OrganizationUserType.Custom, Permissions.ManageResetPassword: true }
};
return authorized;
}
private async Task<bool> CanRecoverProviderAsync(OrganizationUser targetOrganizationUser)
{
if (!targetOrganizationUser.UserId.HasValue)
{
// If an OrganizationUser is not linked to a User then it can't be linked to a Provider either.
// This is invalid but does not pose a privilege escalation risk. Return early and let the command
// handle the invalid input.
return true;
}
var targetUserProviderUsers =
await providerUserRepository.GetManyByUserAsync(targetOrganizationUser.UserId.Value);
// If the target user belongs to any provider that the current user is not a member of,
// deny the action to prevent privilege escalation from organization to provider.
// Note: we do not expect that a user is a member of more than 1 provider, but there is also no guarantee
// against it; this returns a sequence, so we handle the possibility.
var authorized = targetUserProviderUsers.All(providerUser => currentContext.ProviderUser(providerUser.ProviderId));
return authorized;
}
}

View File

@@ -3,6 +3,7 @@
using Bit.Api.Models.Response; using Bit.Api.Models.Response;
using Bit.Api.Utilities; using Bit.Api.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Enums; using Bit.Core.Enums;
@@ -31,10 +32,11 @@ public class EventsController : Controller
private readonly ISecretRepository _secretRepository; private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository; private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository; private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController( public EventsController(IUserService userService,
IUserService userService,
ICipherRepository cipherRepository, ICipherRepository cipherRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository, IProviderUserRepository providerUserRepository,
@@ -42,7 +44,9 @@ public class EventsController : Controller
ICurrentContext currentContext, ICurrentContext currentContext,
ISecretRepository secretRepository, ISecretRepository secretRepository,
IProjectRepository projectRepository, IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository) IServiceAccountRepository serviceAccountRepository,
ILogger<EventsController> logger,
IFeatureService featureService)
{ {
_userService = userService; _userService = userService;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
@@ -53,6 +57,8 @@ public class EventsController : Controller
_secretRepository = secretRepository; _secretRepository = secretRepository;
_projectRepository = projectRepository; _projectRepository = projectRepository;
_serviceAccountRepository = serviceAccountRepository; _serviceAccountRepository = serviceAccountRepository;
_logger = logger;
_featureService = featureService;
} }
[HttpGet("")] [HttpGet("")]
@@ -114,6 +120,9 @@ public class EventsController : Controller
var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2, var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken }); new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e)); var responses = result.Data.Select(e => new EventResponseModel(e));
_logger.LogAggregateData(_featureService, orgId, responses, continuationToken, start, end);
return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken); return new ListResponseModel<EventResponseModel>(responses, result.ContinuationToken);
} }

View File

@@ -1,4 +1,5 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME.
#nullable disable #nullable disable
using Bit.Api.AdminConsole.Authorization; using Bit.Api.AdminConsole.Authorization;
@@ -11,6 +12,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core; using Bit.Core;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.DeleteClaimedAccount;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers; using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -70,6 +72,7 @@ public class OrganizationUsersController : Controller
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand; private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand; private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand; private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository, public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
@@ -97,7 +100,8 @@ public class OrganizationUsersController : Controller
IRestoreOrganizationUserCommand restoreOrganizationUserCommand, IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand, IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand, IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand) IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
{ {
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
@@ -126,6 +130,7 @@ public class OrganizationUsersController : Controller
_restoreOrganizationUserCommand = restoreOrganizationUserCommand; _restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand; _initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand; _revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
} }
[HttpGet("{id}")] [HttpGet("{id}")]
@@ -474,21 +479,27 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/reset-password")] [HttpPut("{id}/reset-password")]
[Authorize<ManageAccountRecoveryRequirement>] [Authorize<ManageAccountRecoveryRequirement>]
public async Task PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model) public async Task<IResult> PutResetPassword(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{ {
if (_featureService.IsEnabled(FeatureFlagKeys.AccountRecoveryCommand))
{
// TODO: remove legacy implementation after feature flag is enabled.
return await PutResetPasswordNew(orgId, id, model);
}
// Get the users role, since provider users aren't a member of the organization we use the owner check // Get the users role, since provider users aren't a member of the organization we use the owner check
var orgUserType = await _currentContext.OrganizationOwner(orgId) var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner ? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type; : _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null) if (orgUserType == null)
{ {
throw new NotFoundException(); return TypedResults.NotFound();
} }
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key); var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded) if (result.Succeeded)
{ {
return; return TypedResults.Ok();
} }
foreach (var error in result.Errors) foreach (var error in result.Errors)
@@ -497,9 +508,45 @@ public class OrganizationUsersController : Controller
} }
await Task.Delay(2000); await Task.Delay(2000);
throw new BadRequestException(ModelState); return TypedResults.BadRequest(ModelState);
} }
#nullable enable
// TODO: make sure the route and authorize attributes are maintained when the legacy implementation is removed.
private async Task<IResult> PutResetPasswordNew(Guid orgId, Guid id, [FromBody] OrganizationUserResetPasswordRequestModel model)
{
var targetOrganizationUser = await _organizationUserRepository.GetByIdAsync(id);
if (targetOrganizationUser == null || targetOrganizationUser.OrganizationId != orgId)
{
return TypedResults.NotFound();
}
var authorizationResult = await _authorizationService.AuthorizeAsync(User, targetOrganizationUser, new RecoverAccountAuthorizationRequirement());
if (!authorizationResult.Succeeded)
{
// Return an informative error to show in the UI.
// The Authorize attribute already prevents enumeration by users outside the organization, so this can be specific.
var failureReason = authorizationResult.Failure?.FailureReasons.FirstOrDefault()?.Message ?? RecoverAccountAuthorizationHandler.FailureReason;
// This should be a 403 Forbidden, but that causes a logout on our client apps so we're using 400 Bad Request instead
return TypedResults.BadRequest(new ErrorResponseModel(failureReason));
}
var result = await _adminRecoverAccountCommand.RecoverAccountAsync(orgId, targetOrganizationUser, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return TypedResults.Ok();
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
await Task.Delay(2000);
return TypedResults.BadRequest(ModelState);
}
#nullable disable
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize<ManageUsersRequirement>] [Authorize<ManageUsersRequirement>]
public async Task Remove(Guid orgId, Guid id) public async Task Remove(Guid orgId, Guid id)

View File

@@ -1,11 +1,8 @@
// FIXME: Update this file to be null safe and then delete the line below using System.ComponentModel.DataAnnotations;
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Enums; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Context; using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Models.Request; namespace Bit.Api.AdminConsole.Models.Request;
@@ -16,14 +13,20 @@ public class PolicyRequestModel
public PolicyType? Type { get; set; } public PolicyType? Type { get; set; }
[Required] [Required]
public bool? Enabled { get; set; } public bool? Enabled { get; set; }
public Dictionary<string, object> Data { get; set; } public Dictionary<string, object>? Data { get; set; }
public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext) => new() public async Task<PolicyUpdate> ToPolicyUpdateAsync(Guid organizationId, ICurrentContext currentContext)
{ {
Type = Type!.Value, var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
OrganizationId = organizationId, var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
Data = Data != null ? JsonSerializer.Serialize(Data) : null,
Enabled = Enabled.GetValueOrDefault(), return new()
PerformedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)) {
}; Type = Type!.Value,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = performedBy
};
}
} }

View File

@@ -1,10 +1,8 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request; namespace Bit.Api.AdminConsole.Models.Request;
@@ -17,45 +15,10 @@ public class SavePolicyRequest
public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext) public async Task<SavePolicyModel> ToSavePolicyModelAsync(Guid organizationId, ICurrentContext currentContext)
{ {
var policyUpdate = await Policy.ToPolicyUpdateAsync(organizationId, currentContext);
var metadata = PolicyDataValidator.ValidateAndDeserializeMetadata(Metadata, Policy.Type!.Value);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId)); var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
var updatedPolicy = new PolicyUpdate() return new SavePolicyModel(policyUpdate, performedBy, metadata);
{
Type = Policy.Type!.Value,
OrganizationId = organizationId,
Data = Policy.Data != null ? JsonSerializer.Serialize(Policy.Data) : null,
Enabled = Policy.Enabled.GetValueOrDefault(),
};
var metadata = MapToPolicyMetadata();
return new SavePolicyModel(updatedPolicy, performedBy, metadata);
}
private IPolicyMetadataModel MapToPolicyMetadata()
{
if (Metadata == null)
{
return new EmptyMetadataModel();
}
return Policy?.Type switch
{
PolicyType.OrganizationDataOwnership => MapToPolicyMetadata<OrganizationModelOwnershipPolicyModel>(),
_ => new EmptyMetadataModel()
};
}
private IPolicyMetadataModel MapToPolicyMetadata<T>() where T : IPolicyMetadataModel, new()
{
try
{
var json = JsonSerializer.Serialize(Metadata);
return CoreHelpers.LoadClassFromJsonData<T>(json);
}
catch
{
return new EmptyMetadataModel();
}
} }
} }

View File

@@ -0,0 +1,127 @@
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response;
/// <summary>
/// Contains organization properties for both OrganizationUsers and ProviderUsers.
/// Any organization properties in sync data should be added to this class so they are populated for both
/// members and providers.
/// </summary>
public abstract class BaseProfileOrganizationResponseModel : ResponseModel
{
protected BaseProfileOrganizationResponseModel(
string type, IProfileOrganizationDetails organizationDetails) : base(type)
{
Id = organizationDetails.OrganizationId;
UserId = organizationDetails.UserId;
Name = organizationDetails.Name;
Enabled = organizationDetails.Enabled;
Identifier = organizationDetails.Identifier;
ProductTierType = organizationDetails.PlanType.GetProductTier();
UsePolicies = organizationDetails.UsePolicies;
UseSso = organizationDetails.UseSso;
UseKeyConnector = organizationDetails.UseKeyConnector;
UseScim = organizationDetails.UseScim;
UseGroups = organizationDetails.UseGroups;
UseDirectory = organizationDetails.UseDirectory;
UseEvents = organizationDetails.UseEvents;
UseTotp = organizationDetails.UseTotp;
Use2fa = organizationDetails.Use2fa;
UseApi = organizationDetails.UseApi;
UseResetPassword = organizationDetails.UseResetPassword;
UsersGetPremium = organizationDetails.UsersGetPremium;
UseCustomPermissions = organizationDetails.UseCustomPermissions;
UseActivateAutofillPolicy = organizationDetails.PlanType.GetProductTier() == ProductTierType.Enterprise;
UseRiskInsights = organizationDetails.UseRiskInsights;
UseOrganizationDomains = organizationDetails.UseOrganizationDomains;
UseAdminSponsoredFamilies = organizationDetails.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organizationDetails.UseAutomaticUserConfirmation;
UseSecretsManager = organizationDetails.UseSecretsManager;
UsePasswordManager = organizationDetails.UsePasswordManager;
SelfHost = organizationDetails.SelfHost;
Seats = organizationDetails.Seats;
MaxCollections = organizationDetails.MaxCollections;
MaxStorageGb = organizationDetails.MaxStorageGb;
Key = organizationDetails.Key;
HasPublicAndPrivateKeys = organizationDetails.PublicKey != null && organizationDetails.PrivateKey != null;
SsoBound = !string.IsNullOrWhiteSpace(organizationDetails.SsoExternalId);
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organizationDetails.ResetPasswordKey);
ProviderId = organizationDetails.ProviderId;
ProviderName = organizationDetails.ProviderName;
ProviderType = organizationDetails.ProviderType;
LimitCollectionCreation = organizationDetails.LimitCollectionCreation;
LimitCollectionDeletion = organizationDetails.LimitCollectionDeletion;
LimitItemDeletion = organizationDetails.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organizationDetails.AllowAdminAccessToAllCollectionItems;
SsoEnabled = organizationDetails.SsoEnabled ?? false;
if (organizationDetails.SsoConfig != null)
{
var ssoConfigData = SsoConfigurationData.Deserialize(organizationDetails.SsoConfig);
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
}
}
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } = null!;
public bool Enabled { get; set; }
public string? Identifier { get; set; }
public ProductTierType ProductTierType { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }
public bool UseTotp { get; set; }
public bool Use2fa { get; set; }
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string? Key { get; set; }
public bool HasPublicAndPrivateKeys { get; set; }
public bool SsoBound { get; set; }
public bool ResetPasswordEnrolled { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public bool SsoEnabled { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string? KeyConnectorUrl { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool AccessSecretsManager { get; set; }
public Guid? UserId { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
public Permissions? Permissions { get; set; }
}

View File

@@ -1,150 +1,47 @@
// FIXME: Update this file to be null safe and then delete the line below using Bit.Core.Enums;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Auth.Enums;
using Bit.Core.Auth.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Api;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers; using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Response; namespace Bit.Api.AdminConsole.Models.Response;
public class ProfileOrganizationResponseModel : ResponseModel /// <summary>
/// Sync data for organization members and their organization.
/// Note: see <see cref="ProfileProviderOrganizationResponseModel"/> for organization sync data received by provider users.
/// </summary>
public class ProfileOrganizationResponseModel : BaseProfileOrganizationResponseModel
{ {
public ProfileOrganizationResponseModel(string str) : base(str) { }
public ProfileOrganizationResponseModel( public ProfileOrganizationResponseModel(
OrganizationUserOrganizationDetails organization, OrganizationUserOrganizationDetails organizationDetails,
IEnumerable<Guid> organizationIdsClaimingUser) IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization") : base("profileOrganization", organizationDetails)
{ {
Id = organization.OrganizationId; Status = organizationDetails.Status;
Name = organization.Name; Type = organizationDetails.Type;
UsePolicies = organization.UsePolicies; OrganizationUserId = organizationDetails.OrganizationUserId;
UseSso = organization.UseSso; UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organizationDetails.OrganizationId);
UseKeyConnector = organization.UseKeyConnector; Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organizationDetails.Permissions);
UseScim = organization.UseScim; IsAdminInitiated = organizationDetails.IsAdminInitiated ?? false;
UseGroups = organization.UseGroups; FamilySponsorshipFriendlyName = organizationDetails.FamilySponsorshipFriendlyName;
UseDirectory = organization.UseDirectory; FamilySponsorshipLastSyncDate = organizationDetails.FamilySponsorshipLastSyncDate;
UseEvents = organization.UseEvents; FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
UseTotp = organization.UseTotp; FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
Use2fa = organization.Use2fa; FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UseSecretsManager = organization.UseSecretsManager;
UsePasswordManager = organization.UsePasswordManager;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost;
Seats = organization.Seats;
MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb;
Key = organization.Key;
HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null;
Status = organization.Status;
Type = organization.Type;
Enabled = organization.Enabled;
SsoBound = !string.IsNullOrWhiteSpace(organization.SsoExternalId);
Identifier = organization.Identifier;
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organization.Permissions);
ResetPasswordEnrolled = !string.IsNullOrWhiteSpace(organization.ResetPasswordKey);
UserId = organization.UserId;
OrganizationUserId = organization.OrganizationUserId;
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
FamilySponsorshipFriendlyName = organization.FamilySponsorshipFriendlyName;
IsAdminInitiated = organization.IsAdminInitiated ?? false;
FamilySponsorshipAvailable = (FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise) StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organization); .UsersCanSponsor(organizationDetails);
ProductTierType = organization.PlanType.GetProductTier(); AccessSecretsManager = organizationDetails.AccessSecretsManager;
FamilySponsorshipLastSyncDate = organization.FamilySponsorshipLastSyncDate;
FamilySponsorshipToDelete = organization.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organization.FamilySponsorshipValidUntil;
AccessSecretsManager = organization.AccessSecretsManager;
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organization.OrganizationId);
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
SsoEnabled = organization.SsoEnabled ?? false;
if (organization.SsoConfig != null)
{
var ssoConfigData = SsoConfigurationData.Deserialize(organization.SsoConfig);
KeyConnectorEnabled = ssoConfigData.MemberDecryptionType == MemberDecryptionType.KeyConnector && !string.IsNullOrEmpty(ssoConfigData.KeyConnectorUrl);
KeyConnectorUrl = ssoConfigData.KeyConnectorUrl;
SsoMemberDecryptionType = ssoConfigData.MemberDecryptionType;
}
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
public Guid Id { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
public bool UseScim { get; set; }
public bool UseGroups { get; set; }
public bool UseDirectory { get; set; }
public bool UseEvents { get; set; }
public bool UseTotp { get; set; }
public bool Use2fa { get; set; }
public bool UseApi { get; set; }
public bool UseResetPassword { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseActivateAutofillPolicy { get; set; }
public bool SelfHost { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public OrganizationUserStatusType Status { get; set; }
public OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public bool SsoBound { get; set; }
public string Identifier { get; set; }
public Permissions Permissions { get; set; }
public bool ResetPasswordEnrolled { get; set; }
public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; } public Guid OrganizationUserId { get; set; }
public bool HasPublicAndPrivateKeys { get; set; } public bool UserIsClaimedByOrganization { get; set; }
public Guid? ProviderId { get; set; } public string? FamilySponsorshipFriendlyName { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public bool FamilySponsorshipAvailable { get; set; } public bool FamilySponsorshipAvailable { get; set; }
public ProductTierType ProductTierType { get; set; }
public bool KeyConnectorEnabled { get; set; }
public string KeyConnectorUrl { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; } public bool? FamilySponsorshipToDelete { get; set; }
public bool AccessSecretsManager { get; set; } public bool IsAdminInitiated { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
/// <summary> /// <summary>
/// Obsolete. /// Obsolete property for backward compatibility
/// See <see cref="UserIsClaimedByOrganization"/>
/// </summary> /// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")] [Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
public bool UserIsManagedByOrganization public bool UserIsManagedByOrganization
@@ -152,19 +49,4 @@ public class ProfileOrganizationResponseModel : ResponseModel
get => UserIsClaimedByOrganization; get => UserIsClaimedByOrganization;
set => UserIsClaimedByOrganization = value; set => UserIsClaimedByOrganization = value;
} }
/// <summary>
/// Indicates if the user is claimed by the organization.
/// </summary>
/// <remarks>
/// A user is claimed by an organization if the user's email domain is verified by the organization and the user is a member.
/// The organization must be enabled and able to have verified domains.
/// </remarks>
public bool UserIsClaimedByOrganization { get; set; }
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public bool IsAdminInitiated { get; set; }
public bool SsoEnabled { get; set; }
public MemberDecryptionType? SsoMemberDecryptionType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; }
} }

View File

@@ -1,57 +1,24 @@
using Bit.Core.AdminConsole.Models.Data.Provider; using Bit.Core.AdminConsole.Models.Data.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Models.Response; namespace Bit.Api.AdminConsole.Models.Response;
public class ProfileProviderOrganizationResponseModel : ProfileOrganizationResponseModel /// <summary>
/// Sync data for provider users and their managed organizations.
/// Note: see <see cref="ProfileOrganizationResponseModel"/> for organization sync data received by organization members.
/// </summary>
public class ProfileProviderOrganizationResponseModel : BaseProfileOrganizationResponseModel
{ {
public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organization) public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organizationDetails)
: base("profileProviderOrganization") : base("profileProviderOrganization", organizationDetails)
{ {
Id = organization.OrganizationId;
Name = organization.Name;
UsePolicies = organization.UsePolicies;
UseSso = organization.UseSso;
UseKeyConnector = organization.UseKeyConnector;
UseScim = organization.UseScim;
UseGroups = organization.UseGroups;
UseDirectory = organization.UseDirectory;
UseEvents = organization.UseEvents;
UseTotp = organization.UseTotp;
Use2fa = organization.Use2fa;
UseApi = organization.UseApi;
UseResetPassword = organization.UseResetPassword;
UsersGetPremium = organization.UsersGetPremium;
UseCustomPermissions = organization.UseCustomPermissions;
UseActivateAutofillPolicy = organization.PlanType.GetProductTier() == ProductTierType.Enterprise;
SelfHost = organization.SelfHost;
Seats = organization.Seats;
MaxCollections = organization.MaxCollections;
MaxStorageGb = organization.MaxStorageGb;
Key = organization.Key;
HasPublicAndPrivateKeys = organization.PublicKey != null && organization.PrivateKey != null;
Status = OrganizationUserStatusType.Confirmed; // Provider users are always confirmed Status = OrganizationUserStatusType.Confirmed; // Provider users are always confirmed
Type = OrganizationUserType.Owner; // Provider users behave like Owners Type = OrganizationUserType.Owner; // Provider users behave like Owners
Enabled = organization.Enabled; ProviderId = organizationDetails.ProviderId;
SsoBound = false; ProviderName = organizationDetails.ProviderName;
Identifier = organization.Identifier; ProviderType = organizationDetails.ProviderType;
Permissions = new Permissions(); Permissions = new Permissions();
ResetPasswordEnrolled = false; AccessSecretsManager = false; // Provider users cannot access Secrets Manager
UserId = organization.UserId;
ProviderId = organization.ProviderId;
ProviderName = organization.ProviderName;
ProviderType = organization.ProviderType;
ProductTierType = organization.PlanType.GetProductTier();
LimitCollectionCreation = organization.LimitCollectionCreation;
LimitCollectionDeletion = organization.LimitCollectionDeletion;
LimitItemDeletion = organization.LimitItemDeletion;
AllowAdminAccessToAllCollectionItems = organization.AllowAdminAccessToAllCollectionItems;
UseRiskInsights = organization.UseRiskInsights;
UseOrganizationDomains = organization.UseOrganizationDomains;
UseAdminSponsoredFamilies = organization.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = organization.UseAutomaticUserConfirmation;
} }
} }

View File

@@ -4,9 +4,11 @@
using System.Net; using System.Net;
using Bit.Api.Models.Public.Request; using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.Context; using Bit.Core.Context;
using Bit.Core.Models.Data; using Bit.Core.Models.Data;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Vault.Repositories; using Bit.Core.Vault.Repositories;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -20,15 +22,21 @@ public class EventsController : Controller
private readonly IEventRepository _eventRepository; private readonly IEventRepository _eventRepository;
private readonly ICipherRepository _cipherRepository; private readonly ICipherRepository _cipherRepository;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController( public EventsController(
IEventRepository eventRepository, IEventRepository eventRepository,
ICipherRepository cipherRepository, ICipherRepository cipherRepository,
ICurrentContext currentContext) ICurrentContext currentContext,
ILogger<EventsController> logger,
IFeatureService featureService)
{ {
_eventRepository = eventRepository; _eventRepository = eventRepository;
_cipherRepository = cipherRepository; _cipherRepository = cipherRepository;
_currentContext = currentContext; _currentContext = currentContext;
_logger = logger;
_featureService = featureService;
} }
/// <summary> /// <summary>
@@ -69,6 +77,8 @@ public class EventsController : Controller
var eventResponses = result.Data.Select(e => new EventResponseModel(e)); var eventResponses = result.Data.Select(e => new EventResponseModel(e));
var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken); var response = new PagedListResponseModel<EventResponseModel>(eventResponses, result.ContinuationToken);
_logger.LogAggregateData(_featureService, _currentContext.OrganizationId!.Value, response, request);
return new JsonResult(response); return new JsonResult(response);
} }
} }

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Net;
#nullable disable
using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request; using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response; using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response; using Bit.Api.Models.Public.Response;
@@ -24,11 +21,9 @@ public class MembersController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IGroupRepository _groupRepository; private readonly IGroupRepository _groupRepository;
private readonly IOrganizationService _organizationService; private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext; private readonly ICurrentContext _currentContext;
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand; private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand; private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly IOrganizationRepository _organizationRepository; private readonly IOrganizationRepository _organizationRepository;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery; private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
@@ -39,11 +34,9 @@ public class MembersController : Controller
IOrganizationUserRepository organizationUserRepository, IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository, IGroupRepository groupRepository,
IOrganizationService organizationService, IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext, ICurrentContext currentContext,
IUpdateOrganizationUserCommand updateOrganizationUserCommand, IUpdateOrganizationUserCommand updateOrganizationUserCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand, IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IApplicationCacheService applicationCacheService,
IPaymentService paymentService, IPaymentService paymentService,
IOrganizationRepository organizationRepository, IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery, ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
@@ -53,11 +46,9 @@ public class MembersController : Controller
_organizationUserRepository = organizationUserRepository; _organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository; _groupRepository = groupRepository;
_organizationService = organizationService; _organizationService = organizationService;
_userService = userService;
_currentContext = currentContext; _currentContext = currentContext;
_updateOrganizationUserCommand = updateOrganizationUserCommand; _updateOrganizationUserCommand = updateOrganizationUserCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand; _updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService; _paymentService = paymentService;
_organizationRepository = organizationRepository; _organizationRepository = organizationRepository;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery; _twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
@@ -115,19 +106,18 @@ public class MembersController : Controller
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Returns a list of your organization's members. /// Returns a list of your organization's members.
/// Member objects listed in this call do not include information about their associated collections. /// Member objects listed in this call include information about their associated collections.
/// </remarks> /// </remarks>
[HttpGet] [HttpGet]
[ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)] [ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List() public async Task<IActionResult> List()
{ {
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId.Value); var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeCollections: true);
// TODO: Get all CollectionUser associations for the organization and marry them up here for the response.
var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails); var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails);
var memberResponses = organizationUserUserDetails.Select(u => var memberResponses = organizationUserUserDetails.Select(u =>
{ {
return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, null); return new MemberResponseModel(u, orgUsersTwoFactorIsEnabled.FirstOrDefault(tuple => tuple.user == u).twoFactorIsEnabled, u.Collections);
}); });
var response = new ListResponseModel<MemberResponseModel>(memberResponses); var response = new ListResponseModel<MemberResponseModel>(memberResponses);
return new JsonResult(response); return new JsonResult(response);
@@ -158,7 +148,7 @@ public class MembersController : Controller
invite.AccessSecretsManager = hasStandaloneSecretsManager; invite.AccessSecretsManager = hasStandaloneSecretsManager;
var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId.Value, null, var user = await _organizationService.InviteUserAsync(_currentContext.OrganizationId!.Value, null,
systemUser: null, invite, model.ExternalId); systemUser: null, invite, model.ExternalId);
var response = new MemberResponseModel(user, invite.Collections); var response = new MemberResponseModel(user, invite.Collections);
return new JsonResult(response); return new JsonResult(response);
@@ -188,12 +178,12 @@ public class MembersController : Controller
var updatedUser = model.ToOrganizationUser(existingUser); var updatedUser = model.ToOrganizationUser(existingUser);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList(); var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups); await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
MemberResponseModel response = null; MemberResponseModel response;
if (existingUser.UserId.HasValue) if (existingUser.UserId.HasValue)
{ {
var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id); var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
response = new MemberResponseModel(existingUserDetails, response = new MemberResponseModel(existingUserDetails!,
await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails), associations); await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails!), associations);
} }
else else
{ {
@@ -242,7 +232,7 @@ public class MembersController : Controller
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId.Value, id, null); await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId!.Value, id, null);
return new OkResult(); return new OkResult();
} }
@@ -264,7 +254,7 @@ public class MembersController : Controller
{ {
return new NotFoundResult(); return new NotFoundResult();
} }
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id); await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
return new OkResult(); return new OkResult();
} }
} }

View File

@@ -1,19 +1,24 @@
using System.Text.Json; using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data; using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums; using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Public.Models.Request; namespace Bit.Api.AdminConsole.Public.Models.Request;
public class PolicyUpdateRequestModel : PolicyBaseModel public class PolicyUpdateRequestModel : PolicyBaseModel
{ {
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) => new() public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type)
{ {
Type = type, var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
OrganizationId = organizationId,
Data = Data != null ? JsonSerializer.Serialize(Data) : null, return new()
Enabled = Enabled.GetValueOrDefault(), {
PerformedBy = new SystemUser(EventSystemUser.PublicApi) Type = type,
}; OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = new SystemUser(EventSystemUser.PublicApi)
};
}
} }

View File

@@ -1,9 +1,15 @@
using Bit.Core.Models.Data; using System.Text.Json.Serialization;
using Bit.Core.Models.Data;
namespace Bit.Api.AdminConsole.Public.Models.Response; namespace Bit.Api.AdminConsole.Public.Models.Response;
public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel
{ {
[JsonConstructor]
public AssociationWithPermissionsResponseModel() : base()
{
}
public AssociationWithPermissionsResponseModel(CollectionAccessSelection selection) public AssociationWithPermissionsResponseModel(CollectionAccessSelection selection)
{ {
if (selection == null) if (selection == null)

View File

@@ -0,0 +1,13 @@
using Bit.Api.Utilities;
namespace Bit.Api.Billing.Attributes;
public class NonTokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{
private static readonly string[] _acceptedValues = ["accountCredit"];
public NonTokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
{
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
}
}

View File

@@ -2,11 +2,11 @@
namespace Bit.Api.Billing.Attributes; namespace Bit.Api.Billing.Attributes;
public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{ {
private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"]; private static readonly string[] _acceptedValues = ["bankAccount", "card", "payPal"];
public PaymentMethodTypeValidationAttribute() : base(_acceptedValues) public TokenizedPaymentMethodTypeValidationAttribute() : base(_acceptedValues)
{ {
ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}"; ErrorMessage = $"Payment method type must be one of: {string.Join(", ", _acceptedValues)}";
} }

View File

@@ -89,19 +89,6 @@ public class OrganizationSponsorshipsController : Controller
throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator."); throw new BadRequestException("Free Bitwarden Families sponsorship has been disabled by your organization administrator.");
} }
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync( var sponsorship = await _createSponsorshipCommand.CreateSponsorshipAsync(
sponsoringOrg, sponsoringOrg,
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),

View File

@@ -3,10 +3,10 @@ using Bit.Core.Billing.Pricing;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace Bit.Api.Controllers; namespace Bit.Api.Billing.Controllers;
[Route("plans")] [Route("plans")]
[Authorize("Web")] [Authorize("Application")]
public class PlansController( public class PlansController(
IPricingClient pricingClient) : Controller IPricingClient pricingClient) : Controller
{ {
@@ -18,4 +18,11 @@ public class PlansController(
var responses = plans.Select(plan => new PlanResponseModel(plan)); var responses = plans.Select(plan => new PlanResponseModel(plan));
return new ListResponseModel<PlanResponseModel>(responses); return new ListResponseModel<PlanResponseModel>(responses);
} }
[HttpGet("premium")]
public async Task<IResult> GetPremiumPlanAsync()
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
return TypedResults.Ok(premiumPlan);
}
} }

View File

@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
public class MinimalTokenizedPaymentMethodRequest public class MinimalTokenizedPaymentMethodRequest
{ {
[Required] [Required]
[PaymentMethodTypeValidation] [TokenizedPaymentMethodTypeValidation]
public required string Type { get; set; } public required string Type { get; set; }
[Required] [Required]

View File

@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Bit.Api.Billing.Attributes;
using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Payment;
public class NonTokenizedPaymentMethodRequest
{
[Required]
[NonTokenizedPaymentMethodTypeValidation]
public required string Type { get; set; }
public NonTokenizedPaymentMethod ToDomain()
{
return Type switch
{
"accountCredit" => new NonTokenizedPaymentMethod { Type = NonTokenizablePaymentMethodType.AccountCredit },
_ => throw new InvalidOperationException($"Invalid value for {nameof(NonTokenizedPaymentMethod)}.{nameof(NonTokenizedPaymentMethod.Type)}")
};
}
}

View File

@@ -4,10 +4,10 @@ using Bit.Core.Billing.Payment.Models;
namespace Bit.Api.Billing.Models.Requests.Premium; namespace Bit.Api.Billing.Models.Requests.Premium;
public class PremiumCloudHostedSubscriptionRequest public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
{ {
[Required] public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; }
public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; } public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; }
[Required] [Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; } public required MinimalBillingAddressRequest BillingAddress { get; set; }
@@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest
[Range(0, 99)] [Range(0, 99)]
public short AdditionalStorageGb { get; set; } = 0; public short AdditionalStorageGb { get; set; } = 0;
public (TokenizedPaymentMethod, BillingAddress, short) ToDomain()
public (PaymentMethod, BillingAddress, short) ToDomain()
{ {
var paymentMethod = TokenizedPaymentMethod.ToDomain(); // Check if TokenizedPaymentMethod or NonTokenizedPaymentMethod is provided.
var tokenizedPaymentMethod = TokenizedPaymentMethod?.ToDomain();
var nonTokenizedPaymentMethod = NonTokenizedPaymentMethod?.ToDomain();
PaymentMethod paymentMethod = tokenizedPaymentMethod != null
? tokenizedPaymentMethod
: nonTokenizedPaymentMethod!;
var billingAddress = BillingAddress.ToDomain(); var billingAddress = BillingAddress.ToDomain();
return (paymentMethod, billingAddress, AdditionalStorageGb); return (paymentMethod, billingAddress, AdditionalStorageGb);
} }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (TokenizedPaymentMethod == null && NonTokenizedPaymentMethod == null)
{
yield return new ValidationResult(
"Either TokenizedPaymentMethod or NonTokenizedPaymentMethod must be provided.",
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
);
}
if (TokenizedPaymentMethod != null && NonTokenizedPaymentMethod != null)
{
yield return new ValidationResult(
"Only one of TokenizedPaymentMethod or NonTokenizedPaymentMethod can be provided.",
new[] { nameof(TokenizedPaymentMethod), nameof(NonTokenizedPaymentMethod) }
);
}
}
} }

View File

@@ -1,45 +0,0 @@
using Bit.Api.Models.Request;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace Bit.Api.Controllers;
public class MiscController : Controller
{
private readonly BitPayClient _bitPayClient;
private readonly GlobalSettings _globalSettings;
public MiscController(
BitPayClient bitPayClient,
GlobalSettings globalSettings)
{
_bitPayClient = bitPayClient;
_globalSettings = globalSettings;
}
[Authorize("Application")]
[HttpPost("~/bitpay-invoice")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<string> PostBitPayInvoice([FromBody] BitPayInvoiceRequestModel model)
{
var invoice = await _bitPayClient.CreateInvoiceAsync(model.ToBitpayInvoice(_globalSettings));
return invoice.Url;
}
[Authorize("Application")]
[HttpPost("~/setup-payment")]
[SelfHosted(NotSelfHostedOnly = true)]
public async Task<string> PostSetupPayment()
{
var options = new SetupIntentCreateOptions
{
Usage = "off_session"
};
var service = new SetupIntentService();
var setupIntent = await service.CreateAsync(options);
return setupIntent.ClientSecret;
}
}

View File

@@ -55,19 +55,6 @@ public class SelfHostedOrganizationSponsorshipsController : Controller
[HttpPost("{sponsoringOrgId}/families-for-enterprise")] [HttpPost("{sponsoringOrgId}/families-for-enterprise")]
public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model) public async Task CreateSponsorship(Guid sponsoringOrgId, [FromBody] OrganizationSponsorshipCreateRequestModel model)
{ {
if (!_featureService.IsEnabled(Bit.Core.FeatureFlagKeys.PM17772_AdminInitiatedSponsorships))
{
if (model.IsAdminInitiated.GetValueOrDefault())
{
throw new BadRequestException();
}
if (!string.IsNullOrWhiteSpace(model.Notes))
{
model.Notes = null;
}
}
await _offerSponsorshipCommand.CreateSponsorshipAsync( await _offerSponsorshipCommand.CreateSponsorshipAsync(
await _organizationRepository.GetByIdAsync(sponsoringOrgId), await _organizationRepository.GetByIdAsync(sponsoringOrgId),
await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default), await _organizationUserRepository.GetByOrganizationAsync(sponsoringOrgId, _currentContext.UserId ?? default),

View File

@@ -1,4 +1,5 @@
using Bit.Core.Context; using Bit.Api.Dirt.Models.Response;
using Bit.Core.Context;
using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces; using Bit.Core.Dirt.Reports.ReportFeatures.Interfaces;
using Bit.Core.Dirt.Reports.ReportFeatures.Requests; using Bit.Core.Dirt.Reports.ReportFeatures.Requests;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@@ -61,8 +62,9 @@ public class OrganizationReportsController : Controller
} }
var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId); var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId);
var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport);
return Ok(latestReport); return Ok(response);
} }
[HttpGet("{organizationId}/{reportId}")] [HttpGet("{organizationId}/{reportId}")]
@@ -102,7 +104,8 @@ public class OrganizationReportsController : Controller
} }
var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request); var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
return Ok(report); var response = report == null ? null : new OrganizationReportResponseModel(report);
return Ok(response);
} }
[HttpPatch("{organizationId}/{reportId}")] [HttpPatch("{organizationId}/{reportId}")]
@@ -119,7 +122,8 @@ public class OrganizationReportsController : Controller
} }
var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request); var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request);
return Ok(updatedReport); var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
} }
#endregion #endregion
@@ -182,10 +186,10 @@ public class OrganizationReportsController : Controller
{ {
throw new BadRequestException("Report ID in the request body must match the route parameter"); throw new BadRequestException("Report ID in the request body must match the route parameter");
} }
var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request); var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(updatedReport); return Ok(response);
} }
#endregion #endregion
@@ -228,7 +232,9 @@ public class OrganizationReportsController : Controller
} }
var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request); var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request);
return Ok(updatedReport); var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
} }
#endregion #endregion
@@ -265,7 +271,6 @@ public class OrganizationReportsController : Controller
{ {
try try
{ {
if (!await _currentContext.AccessReports(organizationId)) if (!await _currentContext.AccessReports(organizationId))
{ {
throw new NotFoundException(); throw new NotFoundException();
@@ -282,10 +287,9 @@ public class OrganizationReportsController : Controller
} }
var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request); var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
return Ok(updatedReport);
} }
catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException)) catch (Exception ex) when (!(ex is BadRequestException || ex is NotFoundException))
{ {

View File

@@ -0,0 +1,38 @@
using Bit.Core.Dirt.Entities;
namespace Bit.Api.Dirt.Models.Response;
public class OrganizationReportResponseModel
{
public Guid Id { get; set; }
public Guid OrganizationId { get; set; }
public string? ReportData { get; set; }
public string? ContentEncryptionKey { get; set; }
public string? SummaryData { get; set; }
public string? ApplicationData { get; set; }
public int? PasswordCount { get; set; }
public int? PasswordAtRiskCount { get; set; }
public int? MemberCount { get; set; }
public DateTime? CreationDate { get; set; } = null;
public DateTime? RevisionDate { get; set; } = null;
public OrganizationReportResponseModel(OrganizationReport organizationReport)
{
if (organizationReport == null)
{
return;
}
Id = organizationReport.Id;
OrganizationId = organizationReport.OrganizationId;
ReportData = organizationReport.ReportData;
ContentEncryptionKey = organizationReport.ContentEncryptionKey;
SummaryData = organizationReport.SummaryData;
ApplicationData = organizationReport.ApplicationData;
PasswordCount = organizationReport.PasswordCount;
PasswordAtRiskCount = organizationReport.PasswordAtRiskCount;
MemberCount = organizationReport.MemberCount;
CreationDate = organizationReport.CreationDate;
RevisionDate = organizationReport.RevisionDate;
}
}

View File

@@ -1,73 +0,0 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.ComponentModel.DataAnnotations;
using Bit.Core.Settings;
namespace Bit.Api.Models.Request;
public class BitPayInvoiceRequestModel : IValidatableObject
{
public Guid? UserId { get; set; }
public Guid? OrganizationId { get; set; }
public Guid? ProviderId { get; set; }
public bool Credit { get; set; }
[Required]
public decimal? Amount { get; set; }
public string ReturnUrl { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public BitPayLight.Models.Invoice.Invoice ToBitpayInvoice(GlobalSettings globalSettings)
{
var inv = new BitPayLight.Models.Invoice.Invoice
{
Price = Convert.ToDouble(Amount.Value),
Currency = "USD",
RedirectUrl = ReturnUrl,
Buyer = new BitPayLight.Models.Invoice.Buyer
{
Email = Email,
Name = Name
},
NotificationUrl = globalSettings.BitPay.NotificationUrl,
FullNotifications = true,
ExtendedNotifications = true
};
var posData = string.Empty;
if (UserId.HasValue)
{
posData = "userId:" + UserId.Value;
}
else if (OrganizationId.HasValue)
{
posData = "organizationId:" + OrganizationId.Value;
}
else if (ProviderId.HasValue)
{
posData = "providerId:" + ProviderId.Value;
}
if (Credit)
{
posData += ",accountCredit:1";
inv.ItemDesc = "Bitwarden Account Credit";
}
else
{
inv.ItemDesc = "Bitwarden";
}
inv.PosData = posData;
return inv;
}
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!UserId.HasValue && !OrganizationId.HasValue && !ProviderId.HasValue)
{
yield return new ValidationResult("User, Organization or Provider is required.");
}
}
}

View File

@@ -94,9 +94,6 @@ public class Startup
services.AddMemoryCache(); services.AddMemoryCache();
services.AddDistributedCache(globalSettings); services.AddDistributedCache(globalSettings);
// BitPay
services.AddSingleton<BitPayClient>();
if (!globalSettings.SelfHosted) if (!globalSettings.SelfHosted)
{ {
services.AddIpRateLimiting(globalSettings); services.AddIpRateLimiting(globalSettings);

View File

@@ -74,10 +74,14 @@ public class ImportCiphersController : Controller
throw new BadRequestException("You cannot import this much data at once."); throw new BadRequestException("You cannot import this much data at once.");
} }
if (model.Ciphers.Any(c => c.ArchivedDate.HasValue))
{
throw new BadRequestException("You cannot import archived items into an organization.");
}
var orgId = new Guid(organizationId); var orgId = new Guid(organizationId);
var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList(); var collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();
//An User is allowed to import if CanCreate Collections or has AccessToImportExport //An User is allowed to import if CanCreate Collections or has AccessToImportExport
var authorized = await CheckOrgImportPermission(collections, orgId); var authorized = await CheckOrgImportPermission(collections, orgId);
if (!authorized) if (!authorized)
@@ -156,7 +160,7 @@ public class ImportCiphersController : Controller
if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded) if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{ {
return true; return true;
}; }
return false; return false;
} }

View File

@@ -0,0 +1,87 @@
using Bit.Api.Models.Public.Request;
using Bit.Api.Models.Public.Response;
using Bit.Core;
using Bit.Core.Services;
namespace Bit.Api.Utilities.DiagnosticTools;
public static class EventDiagnosticLogger
{
public static void LogAggregateData(
this ILogger logger,
IFeatureService featureService,
Guid organizationId,
PagedListResponseModel<EventResponseModel> data, EventFilterRequestModel request)
{
try
{
if (!featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging))
{
return;
}
var orderedRecords = data.Data.OrderBy(e => e.Date).ToList();
var recordCount = orderedRecords.Count;
var newestRecordDate = orderedRecords.LastOrDefault()?.Date.ToString("o");
var oldestRecordDate = orderedRecords.FirstOrDefault()?.Date.ToString("o"); ;
var hasMore = !string.IsNullOrEmpty(data.ContinuationToken);
logger.LogInformation(
"Events query for Organization:{OrgId}. Event count:{Count} newest record:{newestRecord} oldest record:{oldestRecord} HasMore:{HasMore} " +
"Request Filters Start:{QueryStart} End:{QueryEnd} ActingUserId:{ActingUserId} ItemId:{ItemId},",
organizationId,
recordCount,
newestRecordDate,
oldestRecordDate,
hasMore,
request.Start?.ToString("o"),
request.End?.ToString("o"),
request.ActingUserId,
request.ItemId);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Unexpected exception from EventDiagnosticLogger.LogAggregateData");
}
}
public static void LogAggregateData(
this ILogger logger,
IFeatureService featureService,
Guid organizationId,
IEnumerable<Bit.Api.Models.Response.EventResponseModel> data,
string? continuationToken,
DateTime? queryStart = null,
DateTime? queryEnd = null)
{
try
{
if (!featureService.IsEnabled(FeatureFlagKeys.EventDiagnosticLogging))
{
return;
}
var orderedRecords = data.OrderBy(e => e.Date).ToList();
var recordCount = orderedRecords.Count;
var newestRecordDate = orderedRecords.LastOrDefault()?.Date.ToString("o");
var oldestRecordDate = orderedRecords.FirstOrDefault()?.Date.ToString("o"); ;
var hasMore = !string.IsNullOrEmpty(continuationToken);
logger.LogInformation(
"Events query for Organization:{OrgId}. Event count:{Count} newest record:{newestRecord} oldest record:{oldestRecord} HasMore:{HasMore} " +
"Request Filters Start:{QueryStart} End:{QueryEnd}",
organizationId,
recordCount,
newestRecordDate,
oldestRecordDate,
hasMore,
queryStart?.ToString("o"),
queryEnd?.ToString("o"));
}
catch (Exception exception)
{
logger.LogWarning(exception, "Unexpected exception from EventDiagnosticLogger.LogAggregateData");
}
}
}

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be null safe and then delete the line below // FIXME: Update this file to be null safe and then delete the line below
#nullable disable #nullable disable
using System.Globalization;
using System.Text.Json; using System.Text.Json;
using Azure.Messaging.EventGrid; using Azure.Messaging.EventGrid;
using Bit.Api.Auth.Models.Request.Accounts; using Bit.Api.Auth.Models.Request.Accounts;
@@ -1366,7 +1367,7 @@ public class CiphersController : Controller
} }
var (attachmentId, uploadUrl) = await _cipherService.CreateAttachmentForDelayedUploadAsync(cipher, var (attachmentId, uploadUrl) = await _cipherService.CreateAttachmentForDelayedUploadAsync(cipher,
request.Key, request.FileName, request.FileSize, request.AdminRequest, user.Id); request.Key, request.FileName, request.FileSize, request.AdminRequest, user.Id, request.LastKnownRevisionDate);
return new AttachmentUploadDataResponseModel return new AttachmentUploadDataResponseModel
{ {
AttachmentId = attachmentId, AttachmentId = attachmentId,
@@ -1419,9 +1420,11 @@ public class CiphersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream) => await Request.GetFileAsync(async (stream) =>
{ {
await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData); await _cipherService.UploadFileForExistingAttachmentAsync(stream, cipher, attachmentData, lastKnownRevisionDate);
}); });
} }
@@ -1440,10 +1443,12 @@ public class CiphersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) => await Request.GetFileAsync(async (stream, fileName, key) =>
{ {
await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), user.Id); Request.ContentLength.GetValueOrDefault(0), user.Id, false, lastKnownRevisionDate);
}); });
return new CipherResponseModel( return new CipherResponseModel(
@@ -1469,10 +1474,13 @@ public class CiphersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) => await Request.GetFileAsync(async (stream, fileName, key) =>
{ {
await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key, await _cipherService.CreateAttachmentAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), userId, true); Request.ContentLength.GetValueOrDefault(0), userId, true, lastKnownRevisionDate);
}); });
return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp); return new CipherMiniResponseModel(cipher, _globalSettings, cipher.OrganizationUseTotp);
@@ -1515,10 +1523,13 @@ public class CiphersController : Controller
throw new NotFoundException(); throw new NotFoundException();
} }
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (stream, fileName, key) => await Request.GetFileAsync(async (stream, fileName, key) =>
{ {
await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key, await _cipherService.CreateAttachmentShareAsync(cipher, stream, fileName, key,
Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId); Request.ContentLength.GetValueOrDefault(0), attachmentId, organizationId, lastKnownRevisionDate);
}); });
} }
@@ -1630,4 +1641,19 @@ public class CiphersController : Controller
{ {
return await _cipherRepository.GetByIdAsync(cipherId, userId); return await _cipherRepository.GetByIdAsync(cipherId, userId);
} }
private DateTime? GetLastKnownRevisionDateFromForm()
{
DateTime? lastKnownRevisionDate = null;
if (Request.Form.TryGetValue("lastKnownRevisionDate", out var dateValue))
{
if (!DateTime.TryParse(dateValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsedDate))
{
throw new BadRequestException("Invalid lastKnownRevisionDate format.");
}
lastKnownRevisionDate = parsedDate;
}
return lastKnownRevisionDate;
}
} }

View File

@@ -9,4 +9,9 @@ public class AttachmentRequestModel
public string FileName { get; set; } public string FileName { get; set; }
public long FileSize { get; set; } public long FileSize { get; set; }
public bool AdminRequest { get; set; } = false; public bool AdminRequest { get; set; } = false;
/// <summary>
/// The last known revision date of the Cipher that this attachment belongs to.
/// </summary>
public DateTime? LastKnownRevisionDate { get; set; }
} }

View File

@@ -64,7 +64,8 @@
"bitPay": { "bitPay": {
"production": false, "production": false,
"token": "SECRET", "token": "SECRET",
"notificationUrl": "https://bitwarden.com/SECRET" "notificationUrl": "https://bitwarden.com/SECRET",
"webhookKey": "SECRET"
}, },
"amazon": { "amazon": {
"accessKeyId": "SECRET", "accessKeyId": "SECRET",

View File

@@ -8,7 +8,6 @@ public class BillingSettings
public virtual string JobsKey { get; set; } public virtual string JobsKey { get; set; }
public virtual string StripeWebhookKey { get; set; } public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; } public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string BitPayWebhookKey { get; set; }
public virtual string AppleWebhookKey { get; set; } public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings(); public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
public virtual string FreshsalesApiKey { get; set; } public virtual string FreshsalesApiKey { get; set; }

View File

@@ -1,7 +0,0 @@
namespace Bit.Billing.Constants;
public static class BitPayInvoiceStatus
{
public const string Confirmed = "confirmed";
public const string Complete = "complete";
}

View File

@@ -1,125 +1,79 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Globalization;
#nullable disable
using System.Globalization;
using Bit.Billing.Constants;
using Bit.Billing.Models; using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
using Bit.Core.Repositories; using Bit.Core.Repositories;
using Bit.Core.Services; using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities; using Bit.Core.Utilities;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers; namespace Bit.Billing.Controllers;
using static BitPayConstants;
using static StripeConstants;
[Route("bitpay")] [Route("bitpay")]
[ApiExplorerSettings(IgnoreApi = true)] [ApiExplorerSettings(IgnoreApi = true)]
public class BitPayController : Controller public class BitPayController(
GlobalSettings globalSettings,
IBitPayClient bitPayClient,
ITransactionRepository transactionRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IProviderRepository providerRepository,
IMailService mailService,
IPaymentService paymentService,
ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
: Controller
{ {
private readonly BillingSettings _billingSettings;
private readonly BitPayClient _bitPayClient;
private readonly ITransactionRepository _transactionRepository;
private readonly IOrganizationRepository _organizationRepository;
private readonly IUserRepository _userRepository;
private readonly IProviderRepository _providerRepository;
private readonly IMailService _mailService;
private readonly IPaymentService _paymentService;
private readonly ILogger<BitPayController> _logger;
private readonly IPremiumUserBillingService _premiumUserBillingService;
public BitPayController(
IOptions<BillingSettings> billingSettings,
BitPayClient bitPayClient,
ITransactionRepository transactionRepository,
IOrganizationRepository organizationRepository,
IUserRepository userRepository,
IProviderRepository providerRepository,
IMailService mailService,
IPaymentService paymentService,
ILogger<BitPayController> logger,
IPremiumUserBillingService premiumUserBillingService)
{
_billingSettings = billingSettings?.Value;
_bitPayClient = bitPayClient;
_transactionRepository = transactionRepository;
_organizationRepository = organizationRepository;
_userRepository = userRepository;
_providerRepository = providerRepository;
_mailService = mailService;
_paymentService = paymentService;
_logger = logger;
_premiumUserBillingService = premiumUserBillingService;
}
[HttpPost("ipn")] [HttpPost("ipn")]
public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key) public async Task<IActionResult> PostIpn([FromBody] BitPayEventModel model, [FromQuery] string key)
{ {
if (!CoreHelpers.FixedTimeEquals(key, _billingSettings.BitPayWebhookKey)) if (!CoreHelpers.FixedTimeEquals(key, globalSettings.BitPay.WebhookKey))
{ {
return new BadRequestResult(); return new BadRequestObjectResult("Invalid key");
}
if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) ||
string.IsNullOrWhiteSpace(model.Event?.Name))
{
return new BadRequestResult();
} }
if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed) var invoice = await bitPayClient.GetInvoice(model.Data.Id);
{
// Only processing confirmed invoice events for now.
return new OkResult();
}
var invoice = await _bitPayClient.GetInvoiceAsync(model.Data.Id);
if (invoice == null)
{
// Request forged...?
_logger.LogWarning("Invoice not found. #{InvoiceId}", model.Data.Id);
return new BadRequestResult();
}
if (invoice.Status != BitPayInvoiceStatus.Confirmed && invoice.Status != BitPayInvoiceStatus.Complete)
{
_logger.LogWarning("Invoice status of '{InvoiceStatus}' is not acceptable. #{InvoiceId}", invoice.Status, invoice.Id);
return new BadRequestResult();
}
if (invoice.Currency != "USD") if (invoice.Currency != "USD")
{ {
// Only process USD payments logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency);
_logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id); return new BadRequestObjectResult("Cannot process non-USD payments");
return new OkResult();
} }
var (organizationId, userId, providerId) = GetIdsFromPosData(invoice); var (organizationId, userId, providerId) = GetIdsFromPosData(invoice);
if (!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) if ((!organizationId.HasValue && !userId.HasValue && !providerId.HasValue) || !invoice.PosData.Contains(PosDataKeys.AccountCredit))
{ {
return new OkResult(); logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) that had invalid POS data: {PosData}", invoice.Id, invoice.PosData);
return new BadRequestObjectResult("Invalid POS data");
} }
var isAccountCredit = IsAccountCredit(invoice); if (invoice.Status != InvoiceStatuses.Complete)
if (!isAccountCredit)
{ {
// Only processing credits logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}",
_logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id); invoice.Id, invoice.Status);
return new OkResult(); return new OkObjectResult("Waiting for invoice to be completed");
} }
var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id); var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
if (transaction != null) if (existingTransaction != null)
{ {
_logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id); logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id);
return new OkResult(); return new OkObjectResult("Invoice already processed");
} }
try try
{ {
var tx = new Transaction var transaction = new Transaction
{ {
Amount = Convert.ToDecimal(invoice.Price), Amount = Convert.ToDecimal(invoice.Price),
CreationDate = GetTransactionDate(invoice), CreationDate = GetTransactionDate(invoice),
@@ -132,50 +86,47 @@ public class BitPayController : Controller
PaymentMethodType = PaymentMethodType.BitPay, PaymentMethodType = PaymentMethodType.BitPay,
Details = $"{invoice.Currency}, BitPay {invoice.Id}" Details = $"{invoice.Currency}, BitPay {invoice.Id}"
}; };
await _transactionRepository.CreateAsync(tx);
string billingEmail = null; await transactionRepository.CreateAsync(transaction);
if (tx.OrganizationId.HasValue)
var billingEmail = "";
if (transaction.OrganizationId.HasValue)
{ {
var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value); var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value);
if (org != null) if (organization != null)
{ {
billingEmail = org.BillingEmailAddress(); billingEmail = organization.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(org, tx.Amount)) if (await paymentService.CreditAccountAsync(organization, transaction.Amount))
{ {
await _organizationRepository.ReplaceAsync(org); await organizationRepository.ReplaceAsync(organization);
} }
} }
} }
else if (tx.UserId.HasValue) else if (transaction.UserId.HasValue)
{ {
var user = await _userRepository.GetByIdAsync(tx.UserId.Value); var user = await userRepository.GetByIdAsync(transaction.UserId.Value);
if (user != null) if (user != null)
{ {
billingEmail = user.BillingEmailAddress(); billingEmail = user.BillingEmailAddress();
await _premiumUserBillingService.Credit(user, tx.Amount); await premiumUserBillingService.Credit(user, transaction.Amount);
} }
} }
else if (tx.ProviderId.HasValue) else if (transaction.ProviderId.HasValue)
{ {
var provider = await _providerRepository.GetByIdAsync(tx.ProviderId.Value); var provider = await providerRepository.GetByIdAsync(transaction.ProviderId.Value);
if (provider != null) if (provider != null)
{ {
billingEmail = provider.BillingEmailAddress(); billingEmail = provider.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(provider, tx.Amount)) if (await paymentService.CreditAccountAsync(provider, transaction.Amount))
{ {
await _providerRepository.ReplaceAsync(provider); await providerRepository.ReplaceAsync(provider);
} }
} }
} }
else
{
_logger.LogError("Received BitPay account credit transaction that didn't have a user, org, or provider. Invoice#{InvoiceId}", invoice.Id);
}
if (!string.IsNullOrWhiteSpace(billingEmail)) if (!string.IsNullOrWhiteSpace(billingEmail))
{ {
await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount); await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount);
} }
} }
// Catch foreign key violations because user/org could have been deleted. // Catch foreign key violations because user/org could have been deleted.
@@ -186,58 +137,34 @@ public class BitPayController : Controller
return new OkResult(); return new OkResult();
} }
private bool IsAccountCredit(BitPayLight.Models.Invoice.Invoice invoice) private static DateTime GetTransactionDate(Invoice invoice)
{ {
return invoice != null && invoice.PosData != null && invoice.PosData.Contains("accountCredit:1"); var transactions = invoice.Transactions?.Where(transaction =>
transaction.Type == null && !string.IsNullOrWhiteSpace(transaction.Confirmations) &&
transaction.Confirmations != "0").ToList();
return transactions?.Count == 1
? DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)
: CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
} }
private DateTime GetTransactionDate(BitPayLight.Models.Invoice.Invoice invoice) public (Guid? OrganizationId, Guid? UserId, Guid? ProviderId) GetIdsFromPosData(Invoice invoice)
{ {
var transactions = invoice.Transactions?.Where(t => t.Type == null && if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':'))
!string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0");
if (transactions != null && transactions.Count() == 1)
{ {
return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture, return new ValueTuple<Guid?, Guid?, Guid?>(null, null, null);
DateTimeStyles.RoundtripKind);
}
return CoreHelpers.FromEpocMilliseconds(invoice.CurrentTime);
}
public Tuple<Guid?, Guid?, Guid?> GetIdsFromPosData(BitPayLight.Models.Invoice.Invoice invoice)
{
Guid? orgId = null;
Guid? userId = null;
Guid? providerId = null;
if (invoice == null || string.IsNullOrWhiteSpace(invoice.PosData) || !invoice.PosData.Contains(':'))
{
return new Tuple<Guid?, Guid?, Guid?>(null, null, null);
} }
var mainParts = invoice.PosData.Split(','); var ids = invoice.PosData
foreach (var mainPart in mainParts) .Split(',')
{ .Select(part => part.Split(':'))
var parts = mainPart.Split(':'); .Where(parts => parts.Length == 2 && Guid.TryParse(parts[1], out _))
.ToDictionary(parts => parts[0], parts => Guid.Parse(parts[1]));
if (parts.Length <= 1 || !Guid.TryParse(parts[1], out var id)) return new ValueTuple<Guid?, Guid?, Guid?>(
{ ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null,
continue; ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null,
} ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null
);
switch (parts[0])
{
case "userId":
userId = id;
break;
case "organizationId":
orgId = id;
break;
case "providerId":
providerId = id;
break;
}
}
return new Tuple<Guid?, Guid?, Guid?>(orgId, userId, providerId);
} }
} }

View File

@@ -0,0 +1,88 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Quartz;
namespace Bit.Billing.Jobs;
public class ProviderOrganizationDisableJob(
IProviderOrganizationRepository providerOrganizationRepository,
IOrganizationDisableCommand organizationDisableCommand,
ILogger<ProviderOrganizationDisableJob> logger)
: IJob
{
private const int MaxConcurrency = 5;
private const int MaxTimeoutMinutes = 10;
public async Task Execute(IJobExecutionContext context)
{
var providerId = new Guid(context.MergedJobDataMap.GetString("providerId") ?? string.Empty);
var expirationDateString = context.MergedJobDataMap.GetString("expirationDate");
DateTime? expirationDate = string.IsNullOrEmpty(expirationDateString)
? null
: DateTime.Parse(expirationDateString);
logger.LogInformation("Starting to disable organizations for provider {ProviderId}", providerId);
var startTime = DateTime.UtcNow;
var totalProcessed = 0;
var totalErrors = 0;
try
{
var providerOrganizations = await providerOrganizationRepository
.GetManyDetailsByProviderAsync(providerId);
if (providerOrganizations == null || !providerOrganizations.Any())
{
logger.LogInformation("No organizations found for provider {ProviderId}", providerId);
return;
}
logger.LogInformation("Disabling {OrganizationCount} organizations for provider {ProviderId}",
providerOrganizations.Count, providerId);
var semaphore = new SemaphoreSlim(MaxConcurrency, MaxConcurrency);
var tasks = providerOrganizations.Select(async po =>
{
if (DateTime.UtcNow.Subtract(startTime).TotalMinutes > MaxTimeoutMinutes)
{
logger.LogWarning("Timeout reached while disabling organizations for provider {ProviderId}", providerId);
return false;
}
await semaphore.WaitAsync();
try
{
await organizationDisableCommand.DisableAsync(po.OrganizationId, expirationDate);
Interlocked.Increment(ref totalProcessed);
return true;
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to disable organization {OrganizationId} for provider {ProviderId}",
po.OrganizationId, providerId);
Interlocked.Increment(ref totalErrors);
return false;
}
finally
{
semaphore.Release();
}
});
await Task.WhenAll(tasks);
logger.LogInformation("Completed disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}",
providerId, totalProcessed, totalErrors);
}
catch (Exception ex)
{
logger.LogError(ex, "Error disabling organizations for provider {ProviderId}. Processed: {TotalProcessed}, Errors: {TotalErrors}",
providerId, totalProcessed, totalErrors);
throw;
}
}
}

View File

@@ -1,7 +1,11 @@
using Bit.Billing.Constants; using Bit.Billing.Constants;
using Bit.Billing.Jobs;
using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces; using Bit.Core.AdminConsole.OrganizationFeatures.Organizations.Interfaces;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.AdminConsole.Services;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Services; using Bit.Core.Services;
using Quartz;
using Event = Stripe.Event; using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations; namespace Bit.Billing.Services.Implementations;
@@ -11,17 +15,26 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService; private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationDisableCommand _organizationDisableCommand; private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ISchedulerFactory _schedulerFactory;
public SubscriptionDeletedHandler( public SubscriptionDeletedHandler(
IStripeEventService stripeEventService, IStripeEventService stripeEventService,
IUserService userService, IUserService userService,
IStripeEventUtilityService stripeEventUtilityService, IStripeEventUtilityService stripeEventUtilityService,
IOrganizationDisableCommand organizationDisableCommand) IOrganizationDisableCommand organizationDisableCommand,
IProviderRepository providerRepository,
IProviderService providerService,
ISchedulerFactory schedulerFactory)
{ {
_stripeEventService = stripeEventService; _stripeEventService = stripeEventService;
_userService = userService; _userService = userService;
_stripeEventUtilityService = stripeEventUtilityService; _stripeEventUtilityService = stripeEventUtilityService;
_organizationDisableCommand = organizationDisableCommand; _organizationDisableCommand = organizationDisableCommand;
_providerRepository = providerRepository;
_providerService = providerService;
_schedulerFactory = schedulerFactory;
} }
/// <summary> /// <summary>
@@ -53,9 +66,38 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd()); await _organizationDisableCommand.DisableAsync(organizationId.Value, subscription.GetCurrentPeriodEnd());
} }
else if (providerId.HasValue)
{
var provider = await _providerRepository.GetByIdAsync(providerId.Value);
if (provider != null)
{
provider.Enabled = false;
await _providerService.UpdateAsync(provider);
await QueueProviderOrganizationDisableJobAsync(providerId.Value, subscription.GetCurrentPeriodEnd());
}
}
else if (userId.HasValue) else if (userId.HasValue)
{ {
await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd()); await _userService.DisablePremiumAsync(userId.Value, subscription.GetCurrentPeriodEnd());
} }
} }
private async Task QueueProviderOrganizationDisableJobAsync(Guid providerId, DateTime? expirationDate)
{
var scheduler = await _schedulerFactory.GetScheduler();
var job = JobBuilder.Create<ProviderOrganizationDisableJob>()
.WithIdentity($"disable-provider-orgs-{providerId}", "provider-management")
.UsingJobData("providerId", providerId.ToString())
.UsingJobData("expirationDate", expirationDate?.ToString("O"))
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity($"disable-trigger-{providerId}", "provider-management")
.StartNow()
.Build();
await scheduler.ScheduleJob(job, trigger);
}
} }

View File

@@ -51,9 +51,6 @@ public class Startup
// Repositories // Repositories
services.AddDatabaseRepositories(globalSettings); services.AddDatabaseRepositories(globalSettings);
// BitPay Client
services.AddSingleton<BitPayClient>();
// PayPal IPN Client // PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>(); services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

@@ -333,5 +333,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
UseRiskInsights = license.UseRiskInsights; UseRiskInsights = license.UseRiskInsights;
UseOrganizationDomains = license.UseOrganizationDomains; UseOrganizationDomains = license.UseOrganizationDomains;
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies; UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
} }
} }

View File

@@ -45,7 +45,7 @@ public static class PolicyTypeExtensions
PolicyType.MaximumVaultTimeout => "Vault timeout", PolicyType.MaximumVaultTimeout => "Vault timeout",
PolicyType.DisablePersonalVaultExport => "Remove individual vault export", PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
PolicyType.ActivateAutofill => "Active auto-fill", PolicyType.ActivateAutofill => "Active auto-fill",
PolicyType.AutomaticAppLogIn => "Automatically log in users for allowed applications", PolicyType.AutomaticAppLogIn => "Automatic login with SSO",
PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship", PolicyType.FreeFamiliesSponsorshipPolicy => "Remove Free Bitwarden Families sponsorship",
PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN", PolicyType.RemoveUnlockWithPin => "Remove unlock with PIN",
PolicyType.RestrictedItemTypesPolicy => "Restricted item types", PolicyType.RestrictedItemTypesPolicy => "Restricted item types",

View File

@@ -6,6 +6,7 @@ public interface IIntegrationMessage
{ {
IntegrationType IntegrationType { get; } IntegrationType IntegrationType { get; }
string MessageId { get; set; } string MessageId { get; set; }
string? OrganizationId { get; set; }
int RetryCount { get; } int RetryCount { get; }
DateTime? DelayUntilDate { get; } DateTime? DelayUntilDate { get; }
void ApplyRetry(DateTime? handlerDelayUntilDate); void ApplyRetry(DateTime? handlerDelayUntilDate);

View File

@@ -7,6 +7,7 @@ public class IntegrationMessage : IIntegrationMessage
{ {
public IntegrationType IntegrationType { get; set; } public IntegrationType IntegrationType { get; set; }
public required string MessageId { get; set; } public required string MessageId { get; set; }
public string? OrganizationId { get; set; }
public required string RenderedTemplate { get; set; } public required string RenderedTemplate { get; set; }
public int RetryCount { get; set; } = 0; public int RetryCount { get; set; } = 0;
public DateTime? DelayUntilDate { get; set; } public DateTime? DelayUntilDate { get; set; }

View File

@@ -23,7 +23,17 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public Guid? CollectionId => Event.CollectionId; public Guid? CollectionId => Event.CollectionId;
public Guid? GroupId => Event.GroupId; public Guid? GroupId => Event.GroupId;
public Guid? PolicyId => Event.PolicyId; public Guid? PolicyId => Event.PolicyId;
public Guid? IdempotencyId => Event.IdempotencyId;
public Guid? ProviderId => Event.ProviderId;
public Guid? ProviderUserId => Event.ProviderUserId;
public Guid? ProviderOrganizationId => Event.ProviderOrganizationId;
public Guid? InstallationId => Event.InstallationId;
public Guid? SecretId => Event.SecretId;
public Guid? ProjectId => Event.ProjectId;
public Guid? ServiceAccountId => Event.ServiceAccountId;
public Guid? GrantedServiceAccountId => Event.GrantedServiceAccountId;
public string DateIso8601 => Date.ToString("o");
public string EventMessage => JsonSerializer.Serialize(Event); public string EventMessage => JsonSerializer.Serialize(Event);
public User? User { get; set; } public User? User { get; set; }

View File

@@ -0,0 +1,56 @@
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
namespace Bit.Core.AdminConsole.Models.Data;
/// <summary>
/// Interface defining common organization details properties shared between
/// regular organization users and provider organization users for profile endpoints.
/// </summary>
public interface IProfileOrganizationDetails
{
Guid? UserId { get; set; }
Guid OrganizationId { get; set; }
string Name { get; set; }
bool Enabled { get; set; }
PlanType PlanType { get; set; }
bool UsePolicies { get; set; }
bool UseSso { get; set; }
bool UseKeyConnector { get; set; }
bool UseScim { get; set; }
bool UseGroups { get; set; }
bool UseDirectory { get; set; }
bool UseEvents { get; set; }
bool UseTotp { get; set; }
bool Use2fa { get; set; }
bool UseApi { get; set; }
bool UseResetPassword { get; set; }
bool SelfHost { get; set; }
bool UsersGetPremium { get; set; }
bool UseCustomPermissions { get; set; }
bool UseSecretsManager { get; set; }
int? Seats { get; set; }
short? MaxCollections { get; set; }
short? MaxStorageGb { get; set; }
string? Identifier { get; set; }
string? Key { get; set; }
string? ResetPasswordKey { get; set; }
string? PublicKey { get; set; }
string? PrivateKey { get; set; }
string? SsoExternalId { get; set; }
string? Permissions { get; set; }
Guid? ProviderId { get; set; }
string? ProviderName { get; set; }
ProviderType? ProviderType { get; set; }
bool? SsoEnabled { get; set; }
string? SsoConfig { get; set; }
bool UsePasswordManager { get; set; }
bool LimitCollectionCreation { get; set; }
bool LimitCollectionDeletion { get; set; }
bool AllowAdminAccessToAllCollectionItems { get; set; }
bool UseRiskInsights { get; set; }
bool LimitItemDeletion { get; set; }
bool UseAdminSponsoredFamilies { get; set; }
bool UseOrganizationDomains { get; set; }
bool UseAutomaticUserConfirmation { get; set; }
}

View File

@@ -1,20 +1,18 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Text.Json.Serialization;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers; namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserOrganizationDetails public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
{ {
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; } public Guid OrganizationUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } public string Name { get; set; } = null!;
public bool UsePolicies { get; set; } public bool UsePolicies { get; set; }
public bool UseSso { get; set; } public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; } public bool UseKeyConnector { get; set; }
@@ -33,24 +31,24 @@ public class OrganizationUserOrganizationDetails
public int? Seats { get; set; } public int? Seats { get; set; }
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public string Key { get; set; } public string? Key { get; set; }
public Enums.OrganizationUserStatusType Status { get; set; } public Enums.OrganizationUserStatusType Status { get; set; }
public Enums.OrganizationUserType Type { get; set; } public Enums.OrganizationUserType Type { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
public string SsoExternalId { get; set; } public string? SsoExternalId { get; set; }
public string Identifier { get; set; } public string? Identifier { get; set; }
public string Permissions { get; set; } public string? Permissions { get; set; }
public string ResetPasswordKey { get; set; } public string? ResetPasswordKey { get; set; }
public string PublicKey { get; set; } public string? PublicKey { get; set; }
public string PrivateKey { get; set; } public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; } public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; } public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; } public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; } public string? FamilySponsorshipFriendlyName { get; set; }
public bool? SsoEnabled { get; set; } public bool? SsoEnabled { get; set; }
public string SsoConfig { get; set; } public string? SsoConfig { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; } public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { get; set; } public DateTime? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; } public bool? FamilySponsorshipToDelete { get; set; }

View File

@@ -1,19 +1,16 @@
// FIXME: Update this file to be null safe and then delete the line below using System.Text.Json.Serialization;
#nullable disable
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider; using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums; using Bit.Core.Billing.Enums;
using Bit.Core.Utilities; using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider; namespace Bit.Core.AdminConsole.Models.Data.Provider;
public class ProviderUserOrganizationDetails public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
{ {
public Guid OrganizationId { get; set; } public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; } public string Name { get; set; } = null!;
public bool UsePolicies { get; set; } public bool UsePolicies { get; set; }
public bool UseSso { get; set; } public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; } public bool UseKeyConnector { get; set; }
@@ -28,20 +25,22 @@ public class ProviderUserOrganizationDetails
public bool SelfHost { get; set; } public bool SelfHost { get; set; }
public bool UsersGetPremium { get; set; } public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; } public bool UseCustomPermissions { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public int? Seats { get; set; } public int? Seats { get; set; }
public short? MaxCollections { get; set; } public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; } public short? MaxStorageGb { get; set; }
public string Key { get; set; } public string? Key { get; set; }
public ProviderUserStatusType Status { get; set; } public ProviderUserStatusType Status { get; set; }
public ProviderUserType Type { get; set; } public ProviderUserType Type { get; set; }
public bool Enabled { get; set; } public bool Enabled { get; set; }
public string Identifier { get; set; } public string? Identifier { get; set; }
public string PublicKey { get; set; } public string? PublicKey { get; set; }
public string PrivateKey { get; set; } public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; } public Guid? ProviderId { get; set; }
public Guid? ProviderUserId { get; set; } public Guid? ProviderUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))] [JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; } public string? ProviderName { get; set; }
public PlanType PlanType { get; set; } public PlanType PlanType { get; set; }
public bool LimitCollectionCreation { get; set; } public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; } public bool LimitCollectionDeletion { get; set; }
@@ -50,6 +49,11 @@ public class ProviderUserOrganizationDetails
public bool UseRiskInsights { get; set; } public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; } public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; } public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; } public ProviderType? ProviderType { get; set; }
public bool UseAutomaticUserConfirmation { get; set; } public bool UseAutomaticUserConfirmation { get; set; }
public bool? SsoEnabled { get; set; }
public string? SsoConfig { get; set; }
public string? SsoExternalId { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
} }

View File

@@ -0,0 +1,79 @@
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Exceptions;
using Bit.Core.Platform.Push;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
public class AdminRecoverAccountCommand(IOrganizationRepository organizationRepository,
IPolicyRepository policyRepository,
IUserRepository userRepository,
IMailService mailService,
IEventService eventService,
IPushNotificationService pushNotificationService,
IUserService userService,
TimeProvider timeProvider) : IAdminRecoverAccountCommand
{
public async Task<IdentityResult> RecoverAccountAsync(Guid orgId,
OrganizationUser organizationUser, string newMasterPassword, string key)
{
// Org must be able to use reset password
var org = await organizationRepository.GetByIdAsync(orgId);
if (org == null || !org.UseResetPassword)
{
throw new BadRequestException("Organization does not allow password reset.");
}
// Enterprise policy must be enabled
var resetPasswordPolicy =
await policyRepository.GetByOrganizationIdTypeAsync(orgId, PolicyType.ResetPassword);
if (resetPasswordPolicy == null || !resetPasswordPolicy.Enabled)
{
throw new BadRequestException("Organization does not have the password reset policy enabled.");
}
// Org User must be confirmed and have a ResetPasswordKey
if (organizationUser == null ||
organizationUser.Status != OrganizationUserStatusType.Confirmed ||
organizationUser.OrganizationId != orgId ||
string.IsNullOrEmpty(organizationUser.ResetPasswordKey) ||
!organizationUser.UserId.HasValue)
{
throw new BadRequestException("Organization User not valid");
}
var user = await userService.GetUserByIdAsync(organizationUser.UserId.Value);
if (user == null)
{
throw new NotFoundException();
}
if (user.UsesKeyConnector)
{
throw new BadRequestException("Cannot reset password of a user with Key Connector.");
}
var result = await userService.UpdatePasswordHash(user, newMasterPassword);
if (!result.Succeeded)
{
return result;
}
user.RevisionDate = user.AccountRevisionDate = timeProvider.GetUtcNow().UtcDateTime;
user.LastPasswordChangeDate = user.RevisionDate;
user.ForcePasswordReset = true;
user.Key = key;
await userRepository.ReplaceAsync(user);
await mailService.SendAdminResetPasswordEmailAsync(user.Email, user.Name, org.DisplayName());
await eventService.LogOrganizationUserEventAsync(organizationUser, EventType.OrganizationUser_AdminResetPassword);
await pushNotificationService.PushLogOutAsync(user.Id);
return IdentityResult.Success;
}
}

View File

@@ -0,0 +1,24 @@
using Bit.Core.Entities;
using Bit.Core.Exceptions;
using Microsoft.AspNetCore.Identity;
namespace Bit.Core.AdminConsole.OrganizationFeatures.AccountRecovery;
/// <summary>
/// A command used to recover an organization user's account by an organization admin.
/// </summary>
public interface IAdminRecoverAccountCommand
{
/// <summary>
/// Recovers an organization user's account by resetting their master password.
/// </summary>
/// <param name="orgId">The organization the user belongs to.</param>
/// <param name="organizationUser">The organization user being recovered.</param>
/// <param name="newMasterPassword">The user's new master password hash.</param>
/// <param name="key">The user's new master-password-sealed user key.</param>
/// <returns>An IdentityResult indicating success or failure.</returns>
/// <exception cref="BadRequestException">When organization settings, policy, or user state is invalid.</exception>
/// <exception cref="NotFoundException">When the user does not exist.</exception>
Task<IdentityResult> RecoverAccountAsync(Guid orgId, OrganizationUser organizationUser,
string newMasterPassword, string key);
}

View File

@@ -1,6 +1,4 @@
#nullable enable using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies; using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements; using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories; using Bit.Core.AdminConsole.Repositories;
@@ -20,7 +18,7 @@ public class PolicyRequirementQuery(
throw new NotImplementedException("No Requirement Factory found for " + typeof(T)); throw new NotImplementedException("No Requirement Factory found for " + typeof(T));
} }
var policyDetails = await GetPolicyDetails(userId); var policyDetails = await GetPolicyDetails(userId, factory.PolicyType);
var filteredPolicies = policyDetails var filteredPolicies = policyDetails
.Where(p => p.PolicyType == factory.PolicyType) .Where(p => p.PolicyType == factory.PolicyType)
.Where(factory.Enforce); .Where(factory.Enforce);
@@ -48,8 +46,8 @@ public class PolicyRequirementQuery(
return eligibleOrganizationUserIds; return eligibleOrganizationUserIds;
} }
private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId) private async Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetails(Guid userId, PolicyType policyType)
=> policyRepository.GetPolicyDetailsByUserId(userId); => await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType([userId], policyType);
private async Task<IEnumerable<OrganizationPolicyDetails>> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType) private async Task<IEnumerable<OrganizationPolicyDetails>> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType)
=> await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType); => await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType);

View File

@@ -33,6 +33,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>(); services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>(); services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>(); services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>(); services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
} }
@@ -51,6 +52,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyUpdateEvent, MaximumVaultTimeoutPolicyValidator>(); services.AddScoped<IPolicyUpdateEvent, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>(); services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>(); services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
} }
private static void AddPolicyRequirements(this IServiceCollection services) private static void AddPolicyRequirements(this IServiceCollection services)

View File

@@ -0,0 +1,14 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyUpdateEvents.Interfaces;
namespace Bit.Core.AdminConsole.OrganizationFeatures.Policies.PolicyValidators;
public class UriMatchDefaultPolicyValidator : IPolicyValidator, IEnforceDependentPoliciesEvent
{
public PolicyType Type => PolicyType.UriMatchDefaults;
public IEnumerable<PolicyType> RequiredPolicies => [PolicyType.SingleOrg];
public Task<string> ValidateAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.FromResult("");
public Task OnSaveSideEffectsAsync(PolicyUpdate policyUpdate, Policy? currentPolicy) => Task.CompletedTask;
}

View File

@@ -20,17 +20,6 @@ public interface IPolicyRepository : IRepository<Policy, Guid>
Task<Policy?> GetByOrganizationIdTypeAsync(Guid organizationId, PolicyType type); Task<Policy?> GetByOrganizationIdTypeAsync(Guid organizationId, PolicyType type);
Task<ICollection<Policy>> GetManyByOrganizationIdAsync(Guid organizationId); Task<ICollection<Policy>> GetManyByOrganizationIdAsync(Guid organizationId);
Task<ICollection<Policy>> GetManyByUserIdAsync(Guid userId); Task<ICollection<Policy>> GetManyByUserIdAsync(Guid userId);
/// <summary>
/// Gets all PolicyDetails for a user for all policy types.
/// </summary>
/// <remarks>
/// Each PolicyDetail represents an OrganizationUser and a Policy which *may* be enforced
/// against them. It only returns PolicyDetails for policies that are enabled and where the organization's plan
/// supports policies. It also excludes "revoked invited" users who are not subject to policy enforcement.
/// This is consumed by <see cref="IPolicyRequirementQuery"/> to create requirements for specific policy types.
/// You probably do not want to call it directly.
/// </remarks>
Task<IEnumerable<PolicyDetails>> GetPolicyDetailsByUserId(Guid userId);
/// <summary> /// <summary>
/// Retrieves <see cref="OrganizationPolicyDetails"/> of the specified <paramref name="policyType"/> /// Retrieves <see cref="OrganizationPolicyDetails"/> of the specified <paramref name="policyType"/>

View File

@@ -5,5 +5,5 @@ namespace Bit.Core.Services;
public interface IEventIntegrationPublisher : IAsyncDisposable public interface IEventIntegrationPublisher : IAsyncDisposable
{ {
Task PublishAsync(IIntegrationMessage message); Task PublishAsync(IIntegrationMessage message);
Task PublishEventAsync(string body); Task PublishEventAsync(string body, string? organizationId);
} }

View File

@@ -30,7 +30,8 @@ public class AzureServiceBusService : IAzureServiceBusService
var serviceBusMessage = new ServiceBusMessage(json) var serviceBusMessage = new ServiceBusMessage(json)
{ {
Subject = message.IntegrationType.ToRoutingKey(), Subject = message.IntegrationType.ToRoutingKey(),
MessageId = message.MessageId MessageId = message.MessageId,
PartitionKey = message.OrganizationId
}; };
await _integrationSender.SendMessageAsync(serviceBusMessage); await _integrationSender.SendMessageAsync(serviceBusMessage);
@@ -44,18 +45,20 @@ public class AzureServiceBusService : IAzureServiceBusService
{ {
Subject = message.IntegrationType.ToRoutingKey(), Subject = message.IntegrationType.ToRoutingKey(),
ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow, ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow,
MessageId = message.MessageId MessageId = message.MessageId,
PartitionKey = message.OrganizationId
}; };
await _integrationSender.SendMessageAsync(serviceBusMessage); await _integrationSender.SendMessageAsync(serviceBusMessage);
} }
public async Task PublishEventAsync(string body) public async Task PublishEventAsync(string body, string? organizationId)
{ {
var message = new ServiceBusMessage(body) var message = new ServiceBusMessage(body)
{ {
ContentType = "application/json", ContentType = "application/json",
MessageId = Guid.NewGuid().ToString() MessageId = Guid.NewGuid().ToString(),
PartitionKey = organizationId
}; };
await _eventSender.SendMessageAsync(message); await _eventSender.SendMessageAsync(message);

View File

@@ -14,15 +14,21 @@ public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDispo
public async Task CreateAsync(IEvent e) public async Task CreateAsync(IEvent e)
{ {
var body = JsonSerializer.Serialize(e); var body = JsonSerializer.Serialize(e);
await _eventIntegrationPublisher.PublishEventAsync(body: body); await _eventIntegrationPublisher.PublishEventAsync(body: body, organizationId: e.OrganizationId?.ToString());
} }
public async Task CreateManyAsync(IEnumerable<IEvent> events) public async Task CreateManyAsync(IEnumerable<IEvent> events)
{ {
var body = JsonSerializer.Serialize(events); var eventList = events as IList<IEvent> ?? events.ToList();
await _eventIntegrationPublisher.PublishEventAsync(body: body); if (eventList.Count == 0)
} {
return;
}
var organizationId = eventList[0].OrganizationId?.ToString();
var body = JsonSerializer.Serialize(eventList);
await _eventIntegrationPublisher.PublishEventAsync(body: body, organizationId: organizationId);
}
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _eventIntegrationPublisher.DisposeAsync(); await _eventIntegrationPublisher.DisposeAsync();

View File

@@ -57,6 +57,7 @@ public class EventIntegrationHandler<T>(
{ {
IntegrationType = integrationType, IntegrationType = integrationType,
MessageId = messageId.ToString(), MessageId = messageId.ToString(),
OrganizationId = organizationId.ToString(),
Configuration = config, Configuration = config,
RenderedTemplate = renderedTemplate, RenderedTemplate = renderedTemplate,
RetryCount = 0, RetryCount = 0,

View File

@@ -122,7 +122,7 @@ public class RabbitMqService : IRabbitMqService
body: body); body: body);
} }
public async Task PublishEventAsync(string body) public async Task PublishEventAsync(string body, string? organizationId)
{ {
await using var channel = await CreateChannelAsync(); await using var channel = await CreateChannelAsync();
var properties = new BasicProperties var properties = new BasicProperties

View File

@@ -111,5 +111,6 @@ public static class OrganizationFactory
UseRiskInsights = license.UseRiskInsights, UseRiskInsights = license.UseRiskInsights,
UseOrganizationDomains = license.UseOrganizationDomains, UseOrganizationDomains = license.UseOrganizationDomains,
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies, UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation
}; };
} }

View File

@@ -0,0 +1,81 @@
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data.Organizations.Policies;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.Exceptions;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Utilities;
public static class PolicyDataValidator
{
/// <summary>
/// Validates and serializes policy data based on the policy type.
/// </summary>
/// <param name="data">The policy data to validate</param>
/// <param name="policyType">The type of policy</param>
/// <returns>Serialized JSON string if data is valid, null if data is null or empty</returns>
/// <exception cref="BadRequestException">Thrown when data validation fails</exception>
public static string? ValidateAndSerialize(Dictionary<string, object>? data, PolicyType policyType)
{
if (data == null || data.Count == 0)
{
return null;
}
try
{
var json = JsonSerializer.Serialize(data);
switch (policyType)
{
case PolicyType.MasterPassword:
CoreHelpers.LoadClassFromJsonData<MasterPasswordPolicyData>(json);
break;
case PolicyType.SendOptions:
CoreHelpers.LoadClassFromJsonData<SendOptionsPolicyData>(json);
break;
case PolicyType.ResetPassword:
CoreHelpers.LoadClassFromJsonData<ResetPasswordDataModel>(json);
break;
}
return json;
}
catch (JsonException ex)
{
var fieldInfo = !string.IsNullOrEmpty(ex.Path) ? $": field '{ex.Path}' has invalid type" : "";
throw new BadRequestException($"Invalid data for {policyType} policy{fieldInfo}.");
}
}
/// <summary>
/// Validates and deserializes policy metadata based on the policy type.
/// </summary>
/// <param name="metadata">The policy metadata to validate</param>
/// <param name="policyType">The type of policy</param>
/// <returns>Deserialized metadata model, or EmptyMetadataModel if metadata is null, empty, or validation fails</returns>
public static IPolicyMetadataModel ValidateAndDeserializeMetadata(Dictionary<string, object>? metadata, PolicyType policyType)
{
if (metadata == null || metadata.Count == 0)
{
return new EmptyMetadataModel();
}
try
{
var json = JsonSerializer.Serialize(metadata);
return policyType switch
{
PolicyType.OrganizationDataOwnership =>
CoreHelpers.LoadClassFromJsonData<OrganizationModelOwnershipPolicyModel>(json),
_ => new EmptyMetadataModel()
};
}
catch (JsonException)
{
return new EmptyMetadataModel();
}
}
}

View File

@@ -65,7 +65,7 @@ public class EmailTokenProvider : IUserTwoFactorTokenProvider<User>
} }
var code = Encoding.UTF8.GetString(cachedValue); var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code); var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid) if (valid)
{ {
await _distributedCache.RemoveAsync(cacheKey); await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -64,7 +64,7 @@ public class OtpTokenProvider<TOptions>(
} }
var code = Encoding.UTF8.GetString(cachedValue); var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code); var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid) if (valid)
{ {
await _distributedCache.RemoveAsync(cacheKey); await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -0,0 +1,14 @@
namespace Bit.Core.Billing.Constants;
public static class BitPayConstants
{
public static class InvoiceStatuses
{
public const string Complete = "complete";
}
public static class PosDataKeys
{
public const string AccountCredit = "accountCredit:1";
}
}

View File

@@ -1,5 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.Billing.Licenses;
using Bit.Core.Billing.Licenses.Extensions;
using Bit.Core.Billing.Organizations.Models; using Bit.Core.Billing.Organizations.Models;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Exceptions; using Bit.Core.Exceptions;
@@ -52,6 +54,12 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
throw new BadRequestException(exception); throw new BadRequestException(exception);
} }
var useAutomaticUserConfirmation = claimsPrincipal?
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
await WriteLicenseFileAsync(selfHostedOrganization, license); await WriteLicenseFileAsync(selfHostedOrganization, license);
await UpdateOrganizationAsync(selfHostedOrganization, license); await UpdateOrganizationAsync(selfHostedOrganization, license);
} }

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities; using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider; using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients; using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Settings; using Bit.Core.Settings;
@@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Payment.Commands; namespace Bit.Core.Billing.Payment.Commands;
using static BitPayConstants;
public interface ICreateBitPayInvoiceForCreditCommand public interface ICreateBitPayInvoiceForCreditCommand
{ {
Task<BillingCommandResult<string>> Run( Task<BillingCommandResult<string>> Run(
@@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand(
{ {
var (name, email, posData) = GetSubscriberInformation(subscriber); var (name, email, posData) = GetSubscriberInformation(subscriber);
var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}";
var invoice = new Invoice var invoice = new Invoice
{ {
Buyer = new Buyer { Email = email, Name = name }, Buyer = new Buyer { Email = email, Name = name },
@@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand(
ExtendedNotifications = true, ExtendedNotifications = true,
FullNotifications = true, FullNotifications = true,
ItemDesc = "Bitwarden", ItemDesc = "Bitwarden",
NotificationUrl = globalSettings.BitPay.NotificationUrl, NotificationUrl = notificationUrl,
PosData = posData, PosData = posData,
Price = Convert.ToDouble(amount), Price = Convert.ToDouble(amount),
RedirectUrl = redirectUrl RedirectUrl = redirectUrl
@@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand(
private static (string? Name, string? Email, string POSData) GetSubscriberInformation( private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
ISubscriber subscriber) => subscriber switch ISubscriber subscriber) => subscriber switch
{ {
User user => (user.Email, user.Email, $"userId:{user.Id},accountCredit:1"), User user => (user.Email, user.Email, $"userId:{user.Id},{PosDataKeys.AccountCredit}"),
Organization organization => (organization.Name, organization.BillingEmail, Organization organization => (organization.Name, organization.BillingEmail,
$"organizationId:{organization.Id},accountCredit:1"), $"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"), Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"),
_ => throw new ArgumentOutOfRangeException(nameof(subscriber)) _ => throw new ArgumentOutOfRangeException(nameof(subscriber))
}; };
} }

View File

@@ -0,0 +1,11 @@
namespace Bit.Core.Billing.Payment.Models;
public record NonTokenizedPaymentMethod
{
public NonTokenizablePaymentMethodType Type { get; set; }
}
public enum NonTokenizablePaymentMethodType
{
AccountCredit,
}

View File

@@ -0,0 +1,71 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using OneOf;
namespace Bit.Core.Billing.Payment.Models;
[JsonConverter(typeof(PaymentMethodJsonConverter))]
public class PaymentMethod(OneOf<TokenizedPaymentMethod, NonTokenizedPaymentMethod> input)
: OneOfBase<TokenizedPaymentMethod, NonTokenizedPaymentMethod>(input)
{
public static implicit operator PaymentMethod(TokenizedPaymentMethod tokenized) => new(tokenized);
public static implicit operator PaymentMethod(NonTokenizedPaymentMethod nonTokenized) => new(nonTokenized);
public bool IsTokenized => IsT0;
public TokenizedPaymentMethod AsTokenized => AsT0;
public bool IsNonTokenized => IsT1;
public NonTokenizedPaymentMethod AsNonTokenized => AsT1;
}
internal class PaymentMethodJsonConverter : JsonConverter<PaymentMethod>
{
public override PaymentMethod Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var element = JsonElement.ParseValue(ref reader);
if (!element.TryGetProperty("type", out var typeProperty))
{
throw new JsonException("PaymentMethod requires a 'type' property");
}
var type = typeProperty.GetString();
if (Enum.TryParse<TokenizablePaymentMethodType>(type, true, out var tokenizedType) &&
Enum.IsDefined(typeof(TokenizablePaymentMethodType), tokenizedType))
{
var token = element.TryGetProperty("token", out var tokenProperty) ? tokenProperty.GetString() : null;
if (string.IsNullOrEmpty(token))
{
throw new JsonException("TokenizedPaymentMethod requires a 'token' property");
}
return new TokenizedPaymentMethod { Type = tokenizedType, Token = token };
}
if (Enum.TryParse<NonTokenizablePaymentMethodType>(type, true, out var nonTokenizedType) &&
Enum.IsDefined(typeof(NonTokenizablePaymentMethodType), nonTokenizedType))
{
return new NonTokenizedPaymentMethod { Type = nonTokenizedType };
}
throw new JsonException($"Unknown payment method type: {type}");
}
public override void Write(Utf8JsonWriter writer, PaymentMethod value, JsonSerializerOptions options)
{
writer.WriteStartObject();
value.Switch(
tokenized =>
{
writer.WriteString("type",
tokenized.Type.ToString().ToLowerInvariant()
);
writer.WriteString("token", tokenized.Token);
},
nonTokenized => { writer.WriteString("type", nonTokenized.Type.ToString().ToLowerInvariant()); }
);
writer.WriteEndObject();
}
}

View File

@@ -2,7 +2,10 @@
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants; using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Extensions; using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Payment.Queries;
using Bit.Core.Billing.Pricing;
using Bit.Core.Billing.Services; using Bit.Core.Billing.Services;
using Bit.Core.Entities; using Bit.Core.Entities;
using Bit.Core.Enums; using Bit.Core.Enums;
@@ -15,10 +18,12 @@ using Microsoft.Extensions.Logging;
using OneOf.Types; using OneOf.Types;
using Stripe; using Stripe;
using Customer = Stripe.Customer; using Customer = Stripe.Customer;
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
using Subscription = Stripe.Subscription; using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Premium.Commands; namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
using static Utilities; using static Utilities;
/// <summary> /// <summary>
@@ -30,14 +35,14 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
/// <summary> /// <summary>
/// Creates a premium cloud-hosted subscription for the specified user. /// Creates a premium cloud-hosted subscription for the specified user.
/// </summary> /// </summary>
/// <param name="user">The user to create the premium subscription for. Must not already be a premium user.</param> /// <param name="user">The user to create the premium subscription for. Must not yet be a premium user.</param>
/// <param name="paymentMethod">The tokenized payment method containing the payment type and token for billing.</param> /// <param name="paymentMethod">The tokenized payment method containing the payment type and token for billing.</param>
/// <param name="billingAddress">The billing address information required for tax calculation and customer creation.</param> /// <param name="billingAddress">The billing address information required for tax calculation and customer creation.</param>
/// <param name="additionalStorageGb">Additional storage in GB beyond the base 1GB included with premium (must be >= 0).</param> /// <param name="additionalStorageGb">Additional storage in GB beyond the base 1GB included with premium (must be >= 0).</param>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns> /// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run( Task<BillingCommandResult<None>> Run(
User user, User user,
TokenizedPaymentMethod paymentMethod, PaymentMethod paymentMethod,
BillingAddress billingAddress, BillingAddress billingAddress,
short additionalStorageGb); short additionalStorageGb);
} }
@@ -50,7 +55,10 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ISubscriberService subscriberService, ISubscriberService subscriberService,
IUserService userService, IUserService userService,
IPushNotificationService pushNotificationService, IPushNotificationService pushNotificationService,
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger) ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger,
IPricingClient pricingClient,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand)
: BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand : BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand
{ {
private static readonly List<string> _expand = ["tax"]; private static readonly List<string> _expand = ["tax"];
@@ -58,7 +66,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
public Task<BillingCommandResult<None>> Run( public Task<BillingCommandResult<None>> Run(
User user, User user,
TokenizedPaymentMethod paymentMethod, PaymentMethod paymentMethod,
BillingAddress billingAddress, BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () => short additionalStorageGb) => HandleAsync<None>(async () =>
{ {
@@ -72,26 +80,62 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return new BadRequest("Additional storage must be greater than 0."); return new BadRequest("Additional storage must be greater than 0.");
} }
var customer = string.IsNullOrEmpty(user.GatewayCustomerId) Customer? customer;
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand }); /*
* For a new customer purchasing a new subscription, we attach the payment method while creating the customer.
*/
if (string.IsNullOrEmpty(user.GatewayCustomerId))
{
customer = await CreateCustomerAsync(user, paymentMethod, billingAddress);
}
/*
* An existing customer without a payment method starting a new subscription indicates a user who previously
* purchased account credit but chose to use a tokenizable payment method to pay for the subscription. In this case,
* we need to add the payment method to their customer first. If the incoming payment method is account credit,
* we can just go straight to fetching the customer since there's no payment method to apply.
*/
else if (paymentMethod.IsTokenized && !await hasPaymentMethodQuery.Run(user))
{
await updatePaymentMethodCommand.Run(user, paymentMethod.AsTokenized, billingAddress);
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}
else
{
customer = await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
}
customer = await ReconcileBillingLocationAsync(customer, billingAddress); customer = await ReconcileBillingLocationAsync(customer, billingAddress);
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null); var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
switch (paymentMethod) paymentMethod.Switch(
{ tokenized =>
case { Type: TokenizablePaymentMethodType.PayPal } {
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete: // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
case { Type: not TokenizablePaymentMethodType.PayPal } switch (tokenized)
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
{ {
user.Premium = true; case { Type: TokenizablePaymentMethodType.PayPal }
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd(); when subscription.Status == SubscriptionStatus.Incomplete:
break; case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == SubscriptionStatus.Active:
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
}
} }
} },
_ =>
{
if (subscription.Status != SubscriptionStatus.Active)
{
return;
}
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
});
user.Gateway = GatewayType.Stripe; user.Gateway = GatewayType.Stripe;
user.GatewayCustomerId = customer.Id; user.GatewayCustomerId = customer.Id;
@@ -107,9 +151,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
}); });
private async Task<Customer> CreateCustomerAsync(User user, private async Task<Customer> CreateCustomerAsync(User user,
TokenizedPaymentMethod paymentMethod, PaymentMethod paymentMethod,
BillingAddress billingAddress) BillingAddress billingAddress)
{ {
if (paymentMethod.IsNonTokenized)
{
_logger.LogError("Cannot create customer for user ({UserID}) using non-tokenized payment method. The customer should already exist", user.Id);
throw new BillingException();
}
var subscriberName = user.SubscriberName(); var subscriberName = user.SubscriberName();
var customerCreateOptions = new CustomerCreateOptions var customerCreateOptions = new CustomerCreateOptions
{ {
@@ -140,24 +190,25 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
}, },
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
[StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion, [MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
[StripeConstants.MetadataKeys.UserId] = user.Id.ToString() [MetadataKeys.UserId] = user.Id.ToString()
}, },
Tax = new CustomerTaxOptions Tax = new CustomerTaxOptions
{ {
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately ValidateLocation = ValidateTaxLocationTiming.Immediately
} }
}; };
var braintreeCustomerId = ""; var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault // We have checked that the payment method is tokenized, so we can safely cast it.
switch (paymentMethod.Type) var tokenizedPaymentMethod = paymentMethod.AsTokenized;
switch (tokenizedPaymentMethod.Type)
{ {
case TokenizablePaymentMethodType.BankAccount: case TokenizablePaymentMethodType.BankAccount:
{ {
var setupIntent = var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token })) (await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token }))
.FirstOrDefault(); .FirstOrDefault();
if (setupIntent == null) if (setupIntent == null)
@@ -171,19 +222,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
} }
case TokenizablePaymentMethodType.Card: case TokenizablePaymentMethodType.Card:
{ {
customerCreateOptions.PaymentMethod = paymentMethod.Token; customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token; customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = tokenizedPaymentMethod.Token;
break; break;
} }
case TokenizablePaymentMethodType.PayPal: case TokenizablePaymentMethodType.PayPal:
{ {
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token); braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, tokenizedPaymentMethod.Token);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId; customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break; break;
} }
default: default:
{ {
_logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, paymentMethod.Type.ToString()); _logger.LogError("Cannot create customer for user ({UserID}) using payment method type ({PaymentMethodType}) as it is not supported", user.Id, tokenizedPaymentMethod.Type.ToString());
throw new BillingException(); throw new BillingException();
} }
} }
@@ -201,7 +252,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
async Task Revert() async Task Revert()
{ {
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type) switch (tokenizedPaymentMethod.Type)
{ {
case TokenizablePaymentMethodType.BankAccount: case TokenizablePaymentMethodType.BankAccount:
{ {
@@ -244,7 +295,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Expand = _expand, Expand = _expand,
Tax = new CustomerTaxOptions Tax = new CustomerTaxOptions
{ {
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately ValidateLocation = ValidateTaxLocationTiming.Immediately
} }
}; };
return await stripeAdapter.CustomerUpdateAsync(customer.Id, options); return await stripeAdapter.CustomerUpdateAsync(customer.Id, options);
@@ -255,11 +306,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Customer customer, Customer customer,
int? storage) int? storage)
{ {
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions> var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{ {
new () new ()
{ {
Price = StripeConstants.Prices.PremiumAnnually, Price = premiumPlan.Seat.StripePriceId,
Quantity = 1 Quantity = 1
} }
}; };
@@ -268,7 +321,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{ {
subscriptionItemOptionsList.Add(new SubscriptionItemOptions subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{ {
Price = StripeConstants.Prices.StoragePlanPersonal, Price = premiumPlan.Storage.StripePriceId,
Quantity = storage Quantity = storage
}); });
} }
@@ -281,15 +334,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{ {
Enabled = true Enabled = true
}, },
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically, CollectionMethod = CollectionMethod.ChargeAutomatically,
Customer = customer.Id, Customer = customer.Id,
Items = subscriptionItemOptionsList, Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string> Metadata = new Dictionary<string, string>
{ {
[StripeConstants.MetadataKeys.UserId] = userId.ToString() [MetadataKeys.UserId] = userId.ToString()
}, },
PaymentBehavior = usingPayPal PaymentBehavior = usingPayPal
? StripeConstants.PaymentBehavior.DefaultIncomplete ? PaymentBehavior.DefaultIncomplete
: null, : null,
OffSession = true OffSession = true
}; };

View File

@@ -1,14 +1,12 @@
using Bit.Core.Billing.Commands; using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Models; using Bit.Core.Billing.Payment.Models;
using Bit.Core.Billing.Pricing;
using Bit.Core.Services; using Bit.Core.Services;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Stripe; using Stripe;
namespace Bit.Core.Billing.Premium.Commands; namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
public interface IPreviewPremiumTaxCommand public interface IPreviewPremiumTaxCommand
{ {
Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run( Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
@@ -18,6 +16,7 @@ public interface IPreviewPremiumTaxCommand
public class PreviewPremiumTaxCommand( public class PreviewPremiumTaxCommand(
ILogger<PreviewPremiumTaxCommand> logger, ILogger<PreviewPremiumTaxCommand> logger,
IPricingClient pricingClient,
IStripeAdapter stripeAdapter) : BaseBillingCommand<PreviewPremiumTaxCommand>(logger), IPreviewPremiumTaxCommand IStripeAdapter stripeAdapter) : BaseBillingCommand<PreviewPremiumTaxCommand>(logger), IPreviewPremiumTaxCommand
{ {
public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run( public Task<BillingCommandResult<(decimal Tax, decimal Total)>> Run(
@@ -25,6 +24,8 @@ public class PreviewPremiumTaxCommand(
BillingAddress billingAddress) BillingAddress billingAddress)
=> HandleAsync<(decimal, decimal)>(async () => => HandleAsync<(decimal, decimal)>(async () =>
{ {
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var options = new InvoiceCreatePreviewOptions var options = new InvoiceCreatePreviewOptions
{ {
AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true }, AutomaticTax = new InvoiceAutomaticTaxOptions { Enabled = true },
@@ -41,7 +42,7 @@ public class PreviewPremiumTaxCommand(
{ {
Items = Items =
[ [
new InvoiceSubscriptionDetailsItemOptions { Price = Prices.PremiumAnnually, Quantity = 1 } new InvoiceSubscriptionDetailsItemOptions { Price = premiumPlan.Seat.StripePriceId, Quantity = 1 }
] ]
} }
}; };
@@ -50,7 +51,7 @@ public class PreviewPremiumTaxCommand(
{ {
options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions options.SubscriptionDetails.Items.Add(new InvoiceSubscriptionDetailsItemOptions
{ {
Price = Prices.StoragePlanPersonal, Price = premiumPlan.Storage.StripePriceId,
Quantity = additionalStorage Quantity = additionalStorage
}); });
} }

View File

@@ -3,12 +3,14 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.StaticStore; using Bit.Core.Models.StaticStore;
using Bit.Core.Utilities; using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Pricing; namespace Bit.Core.Billing.Pricing;
using OrganizationPlan = Plan;
using PremiumPlan = Premium.Plan;
public interface IPricingClient public interface IPricingClient
{ {
// TODO: Rename with Organization focus.
/// <summary> /// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled, /// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>. /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
@@ -16,8 +18,9 @@ public interface IPricingClient
/// <param name="planType">The type of plan to retrieve.</param> /// <param name="planType">The type of plan to retrieve.</param>
/// <returns>A Bitwarden <see cref="Plan"/> record or null in the case the plan could not be found or the method was executed from a self-hosted instance.</returns> /// <returns>A Bitwarden <see cref="Plan"/> record or null in the case the plan could not be found or the method was executed from a self-hosted instance.</returns>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception> /// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan?> GetPlan(PlanType planType); Task<OrganizationPlan?> GetPlan(PlanType planType);
// TODO: Rename with Organization focus.
/// <summary> /// <summary>
/// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled, /// Retrieve a Bitwarden plan by its <paramref name="planType"/>. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>. /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
@@ -26,13 +29,17 @@ public interface IPricingClient
/// <returns>A Bitwarden <see cref="Plan"/> record.</returns> /// <returns>A Bitwarden <see cref="Plan"/> record.</returns>
/// <exception cref="NotFoundException">Thrown when the <see cref="Plan"/> for the provided <paramref name="planType"/> could not be found or the method was executed from a self-hosted instance.</exception> /// <exception cref="NotFoundException">Thrown when the <see cref="Plan"/> for the provided <paramref name="planType"/> could not be found or the method was executed from a self-hosted instance.</exception>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception> /// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<Plan> GetPlanOrThrow(PlanType planType); Task<OrganizationPlan> GetPlanOrThrow(PlanType planType);
// TODO: Rename with Organization focus.
/// <summary> /// <summary>
/// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled, /// Retrieve all the Bitwarden plans. If the feature flag 'use-pricing-service' is enabled,
/// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>. /// this will trigger a request to the Bitwarden Pricing Service. Otherwise, it will use the existing <see cref="StaticStore"/>.
/// </summary> /// </summary>
/// <returns>A list of Bitwarden <see cref="Plan"/> records or an empty list in the case the method is executed from a self-hosted instance.</returns> /// <returns>A list of Bitwarden <see cref="Plan"/> records or an empty list in the case the method is executed from a self-hosted instance.</returns>
/// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception> /// <exception cref="BillingException">Thrown when the request to the Pricing Service fails unexpectedly.</exception>
Task<List<Plan>> ListPlans(); Task<List<OrganizationPlan>> ListPlans();
Task<PremiumPlan> GetAvailablePremiumPlan();
Task<List<PremiumPlan>> ListPremiumPlans();
} }

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Pricing.Models; namespace Bit.Core.Billing.Pricing.Organizations;
public class Feature public class Feature
{ {

View File

@@ -1,4 +1,4 @@
namespace Bit.Core.Billing.Pricing.Models; namespace Bit.Core.Billing.Pricing.Organizations;
public class Plan public class Plan
{ {

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