1
0
mirror of https://github.com/bitwarden/server synced 2025-12-10 13:23:27 +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)
**/packages.lock.json
Directory.Build.props
# Claude related files
.claude/ @bitwarden/team-ai-sme
.github/workflows/respond.yml @bitwarden/team-ai-sme
.github/workflows/review-code.yml @bitwarden/team-ai-sme

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,5 +17,5 @@ jobs:
- name: Check for label
run: |
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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,9 @@ jobs:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
persist-credentials: false
- name: Check release version
id: version
@@ -52,8 +55,8 @@ jobs:
- name: Get branch name
id: branch
run: |
BRANCH_NAME=$(basename ${{ github.ref }})
echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT
BRANCH_NAME=$(basename "${GITHUB_REF}")
echo "branch-name=$BRANCH_NAME" >> "$GITHUB_OUTPUT"
release:
name: Create GitHub release

View File

@@ -46,7 +46,7 @@ jobs:
BRANCH="hotfix-rc"
fi
echo "branch=$BRANCH" >> $GITHUB_OUTPUT
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
bump_version:
name: Bump Version
@@ -95,6 +95,7 @@ jobs:
with:
ref: main
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
- name: Configure Git
run: |
@@ -110,7 +111,7 @@ jobs:
id: current-version
run: |
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
if: ${{ inputs.version_number_override != '' }}
@@ -120,16 +121,15 @@ jobs:
run: |
# Error if version has not changed.
if [[ "$NEW_VERSION" == "$CURRENT_VERSION" ]]; then
echo "Specified override version is the same as the current version." >> $GITHUB_STEP_SUMMARY
echo "Specified override version is the same as the current version." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
# Check if version is newer.
printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V
if [ $? -eq 0 ]; then
if printf '%s\n' "${CURRENT_VERSION}" "${NEW_VERSION}" | sort -C -V; then
echo "Version is newer than the current version."
else
echo "Version is older than the current version." >> $GITHUB_STEP_SUMMARY
echo "Version is older than the current version." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
@@ -160,15 +160,20 @@ jobs:
id: set-final-version-output
env:
VERSION: ${{ inputs.version_number_override }}
BUMP_VERSION_OVERRIDE_OUTCOME: ${{ steps.bump-version-override.outcome }}
BUMP_VERSION_AUTOMATIC_OUTCOME: ${{ steps.bump-version-automatic.outcome }}
CALCULATE_NEXT_VERSION: ${{ steps.calculate-next-version.outputs.version }}
run: |
if [[ "${{ steps.bump-version-override.outcome }}" = "success" ]]; then
echo "version=$VERSION" >> $GITHUB_OUTPUT
elif [[ "${{ steps.bump-version-automatic.outcome }}" = "success" ]]; then
echo "version=${{ steps.calculate-next-version.outputs.version }}" >> $GITHUB_OUTPUT
if [[ "${BUMP_VERSION_OVERRIDE_OUTCOME}" = "success" ]]; then
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
elif [[ "${BUMP_VERSION_AUTOMATIC_OUTCOME}" = "success" ]]; then
echo "version=${CALCULATE_NEXT_VERSION}" >> "$GITHUB_OUTPUT"
fi
- name: Commit files
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
run: git push
@@ -213,13 +218,15 @@ jobs:
with:
ref: ${{ inputs.target_ref }}
token: ${{ steps.app-token.outputs.token }}
persist-credentials: true
fetch-depth: 0
- name: Check if ${{ needs.setup.outputs.branch }} branch exists
env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
if [[ $(git ls-remote --heads origin $BRANCH_NAME) ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> $GITHUB_STEP_SUMMARY
if [[ $(git ls-remote --heads origin "$BRANCH_NAME") ]]; then
echo "$BRANCH_NAME already exists! Please delete $BRANCH_NAME before running again." >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
@@ -227,8 +234,8 @@ jobs:
env:
BRANCH_NAME: ${{ needs.setup.outputs.branch }}
run: |
git switch --quiet --create $BRANCH_NAME
git push --quiet --set-upstream origin $BRANCH_NAME
git switch --quiet --create "$BRANCH_NAME"
git push --quiet --set-upstream origin "$BRANCH_NAME"
move_edd_db_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:
pull_request:
types: [opened, synchronize, reopened]
types: [opened, synchronize, reopened, ready_for_review]
permissions: {}
jobs:
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:
contents: read
id-token: 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:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
@@ -139,26 +141,26 @@ jobs:
- name: Print MySQL Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mysql")'
run: 'docker logs "$(docker ps --quiet --filter "name=mysql")"'
- name: Print MariaDB Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mariadb")'
run: 'docker logs "$(docker ps --quiet --filter "name=mariadb")"'
- name: Print Postgres Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=postgres")'
run: 'docker logs "$(docker ps --quiet --filter "name=postgres")"'
- name: Print MSSQL Logs
if: failure()
run: 'docker logs $(docker ps --quiet --filter "name=mssql")'
run: 'docker logs "$(docker ps --quiet --filter "name=mssql")"'
- name: Report test results
uses: dorny/test-reporter@890a17cecf52a379fc869ab770a71657660be727 # v2.1.0
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !cancelled() }}
with:
name: Test Results
path: "**/*-test-results.trx"
path: "./**/*-test-results.trx"
reporter: dotnet-trx
fail-on-error: true
@@ -177,6 +179,8 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0

View File

@@ -28,6 +28,8 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up .NET
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
/api.json
/api.public.json
# Serena
.serena/

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Version>2025.10.1</Version>
<Version>2025.11.0</Version>
<RootNamespace>Bit.$(MSBuildProjectName)</RootNamespace>
<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}"
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}") = "SSO.Test", "bitwarden_license\test\SSO.Test\SSO.Test.csproj", "{7D98784C-C253-43FB-9873-25B65C6250D6}"
EndProject
Global
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}.Release|Any CPU.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -419,6 +424,7 @@ Global
{AD59537D-5259-4B7A-948F-0CF58E80B359} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{9F08DFBB-482B-4C9D-A5F4-6BDA6EC2E68F} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84E}
{A2E067EF-609C-4D13-895A-E054C61D48BB} = {DD5BD056-4AAE-43EF-BBD2-0B569B8DA84F}
{7D98784C-C253-43FB-9873-25B65C6250D6} = {287CFF34-BBDB-4BC4-AF88-1E19A5A4679B}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E01CBF68-2E20-425F-9EDB-E0A6510CA92F}

View File

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

View File

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

View File

@@ -16,9 +16,9 @@
"css-loader": "7.1.2",
"expose-loader": "5.0.1",
"mini-css-extract-plugin": "2.9.2",
"sass": "1.91.0",
"sass": "1.93.2",
"sass-loader": "16.0.5",
"webpack": "5.101.3",
"webpack": "5.102.1",
"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")'>
<label class="form-check-label" asp-for="UseCustomPermissions"></label>
</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")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
}
<div class="form-check">
<input type="checkbox" class="form-check-input" asp-for="UseAdminSponsoredFamilies" disabled='@(canEditPlan ? null : "disabled")'>
<label class="form-check-label" asp-for="UseAdminSponsoredFamilies"></label>
</div>
@if(FeatureService.IsEnabled(FeatureFlagKeys.AutomaticConfirmUsers))
{
<div class="form-check">

View File

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

View File

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

View File

@@ -12,10 +12,11 @@ public static class AuthorizationHandlerCollectionExtensions
services.TryAddScoped<IOrganizationContext, OrganizationContext>();
services.TryAddEnumerable([
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, BulkCollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, CollectionAuthorizationHandler>(),
ServiceDescriptor.Scoped<IAuthorizationHandler, GroupAuthorizationHandler>(),
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.Utilities;
using Bit.Api.Utilities.DiagnosticTools;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Context;
using Bit.Core.Enums;
@@ -31,10 +32,11 @@ public class EventsController : Controller
private readonly ISecretRepository _secretRepository;
private readonly IProjectRepository _projectRepository;
private readonly IServiceAccountRepository _serviceAccountRepository;
private readonly ILogger<EventsController> _logger;
private readonly IFeatureService _featureService;
public EventsController(
IUserService userService,
public EventsController(IUserService userService,
ICipherRepository cipherRepository,
IOrganizationUserRepository organizationUserRepository,
IProviderUserRepository providerUserRepository,
@@ -42,7 +44,9 @@ public class EventsController : Controller
ICurrentContext currentContext,
ISecretRepository secretRepository,
IProjectRepository projectRepository,
IServiceAccountRepository serviceAccountRepository)
IServiceAccountRepository serviceAccountRepository,
ILogger<EventsController> logger,
IFeatureService featureService)
{
_userService = userService;
_cipherRepository = cipherRepository;
@@ -53,6 +57,8 @@ public class EventsController : Controller
_secretRepository = secretRepository;
_projectRepository = projectRepository;
_serviceAccountRepository = serviceAccountRepository;
_logger = logger;
_featureService = featureService;
}
[HttpGet("")]
@@ -114,6 +120,9 @@ public class EventsController : Controller
var result = await _eventRepository.GetManyByOrganizationAsync(orgId, dateRange.Item1, dateRange.Item2,
new PageOptions { ContinuationToken = continuationToken });
var responses = result.Data.Select(e => new EventResponseModel(e));
_logger.LogAggregateData(_featureService, orgId, responses, continuationToken, start, end);
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
// NOTE: This file is partially migrated to nullable reference types. Remove inline #nullable directives when addressing the FIXME.
#nullable disable
using Bit.Api.AdminConsole.Authorization;
@@ -11,6 +12,7 @@ using Bit.Api.Vault.AuthorizationHandlers.Collections;
using Bit.Core;
using Bit.Core.AdminConsole.Enums;
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.Interfaces;
using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers;
@@ -70,6 +72,7 @@ public class OrganizationUsersController : Controller
private readonly IRestoreOrganizationUserCommand _restoreOrganizationUserCommand;
private readonly IInitPendingOrganizationCommand _initPendingOrganizationCommand;
private readonly IRevokeOrganizationUserCommand _revokeOrganizationUserCommand;
private readonly IAdminRecoverAccountCommand _adminRecoverAccountCommand;
public OrganizationUsersController(IOrganizationRepository organizationRepository,
IOrganizationUserRepository organizationUserRepository,
@@ -97,7 +100,8 @@ public class OrganizationUsersController : Controller
IRestoreOrganizationUserCommand restoreOrganizationUserCommand,
IInitPendingOrganizationCommand initPendingOrganizationCommand,
IRevokeOrganizationUserCommand revokeOrganizationUserCommand,
IResendOrganizationInviteCommand resendOrganizationInviteCommand)
IResendOrganizationInviteCommand resendOrganizationInviteCommand,
IAdminRecoverAccountCommand adminRecoverAccountCommand)
{
_organizationRepository = organizationRepository;
_organizationUserRepository = organizationUserRepository;
@@ -126,6 +130,7 @@ public class OrganizationUsersController : Controller
_restoreOrganizationUserCommand = restoreOrganizationUserCommand;
_initPendingOrganizationCommand = initPendingOrganizationCommand;
_revokeOrganizationUserCommand = revokeOrganizationUserCommand;
_adminRecoverAccountCommand = adminRecoverAccountCommand;
}
[HttpGet("{id}")]
@@ -474,21 +479,27 @@ public class OrganizationUsersController : Controller
[HttpPut("{id}/reset-password")]
[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
var orgUserType = await _currentContext.OrganizationOwner(orgId)
? OrganizationUserType.Owner
: _currentContext.Organizations?.FirstOrDefault(o => o.Id == orgId)?.Type;
if (orgUserType == null)
{
throw new NotFoundException();
return TypedResults.NotFound();
}
var result = await _userService.AdminResetPasswordAsync(orgUserType.Value, orgId, id, model.NewMasterPasswordHash, model.Key);
if (result.Succeeded)
{
return;
return TypedResults.Ok();
}
foreach (var error in result.Errors)
@@ -497,9 +508,45 @@ public class OrganizationUsersController : Controller
}
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}")]
[Authorize<ManageUsersRequirement>]
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
#nullable disable
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using System.ComponentModel.DataAnnotations;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Context;
namespace Bit.Api.AdminConsole.Models.Request;
@@ -16,14 +13,20 @@ public class PolicyRequestModel
public PolicyType? Type { get; set; }
[Required]
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,
OrganizationId = organizationId,
Data = Data != null ? JsonSerializer.Serialize(Data) : null,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId))
};
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, Type!.Value);
var performedBy = new StandardUser(currentContext.UserId!.Value, await currentContext.OrganizationOwner(organizationId));
return new()
{
Type = Type!.Value,
OrganizationId = organizationId,
Data = serializedData,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = performedBy
};
}
}

View File

@@ -1,10 +1,8 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using Bit.Core.AdminConsole.Enums;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.AdminConsole.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Context;
using Bit.Core.Utilities;
namespace Bit.Api.AdminConsole.Models.Request;
@@ -17,45 +15,10 @@ public class SavePolicyRequest
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 updatedPolicy = new PolicyUpdate()
{
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();
}
return new SavePolicyModel(policyUpdate, performedBy, metadata);
}
}

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
#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.Enums;
using Bit.Core.Models.Data;
using Bit.Core.Models.Data.Organizations.OrganizationUsers;
using Bit.Core.Utilities;
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(
OrganizationUserOrganizationDetails organization,
OrganizationUserOrganizationDetails organizationDetails,
IEnumerable<Guid> organizationIdsClaimingUser)
: this("profileOrganization")
: base("profileOrganization", 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;
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) &&
Status = organizationDetails.Status;
Type = organizationDetails.Type;
OrganizationUserId = organizationDetails.OrganizationUserId;
UserIsClaimedByOrganization = organizationIdsClaimingUser.Contains(organizationDetails.OrganizationId);
Permissions = CoreHelpers.LoadClassFromJsonData<Permissions>(organizationDetails.Permissions);
IsAdminInitiated = organizationDetails.IsAdminInitiated ?? false;
FamilySponsorshipFriendlyName = organizationDetails.FamilySponsorshipFriendlyName;
FamilySponsorshipLastSyncDate = organizationDetails.FamilySponsorshipLastSyncDate;
FamilySponsorshipToDelete = organizationDetails.FamilySponsorshipToDelete;
FamilySponsorshipValidUntil = organizationDetails.FamilySponsorshipValidUntil;
FamilySponsorshipAvailable = (organizationDetails.FamilySponsorshipFriendlyName == null || IsAdminInitiated) &&
StaticStore.GetSponsoredPlan(PlanSponsorshipType.FamiliesForEnterprise)
.UsersCanSponsor(organization);
ProductTierType = organization.PlanType.GetProductTier();
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;
.UsersCanSponsor(organizationDetails);
AccessSecretsManager = organizationDetails.AccessSecretsManager;
}
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 bool HasPublicAndPrivateKeys { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public bool UserIsClaimedByOrganization { get; set; }
public string? FamilySponsorshipFriendlyName { 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? FamilySponsorshipValidUntil { get; set; }
public bool? FamilySponsorshipToDelete { get; set; }
public bool AccessSecretsManager { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
public bool LimitItemDeletion { get; set; }
public bool AllowAdminAccessToAllCollectionItems { get; set; }
public bool IsAdminInitiated { get; set; }
/// <summary>
/// Obsolete.
/// See <see cref="UserIsClaimedByOrganization"/>
/// Obsolete property for backward compatibility
/// </summary>
[Obsolete("Please use UserIsClaimedByOrganization instead. This property will be removed in a future version.")]
public bool UserIsManagedByOrganization
@@ -152,19 +49,4 @@ public class ProfileOrganizationResponseModel : ResponseModel
get => UserIsClaimedByOrganization;
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.Billing.Enums;
using Bit.Core.Billing.Extensions;
using Bit.Core.Enums;
using Bit.Core.Models.Data;
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)
: base("profileProviderOrganization")
public ProfileProviderOrganizationResponseModel(ProviderUserOrganizationDetails organizationDetails)
: 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
Type = OrganizationUserType.Owner; // Provider users behave like Owners
Enabled = organization.Enabled;
SsoBound = false;
Identifier = organization.Identifier;
ProviderId = organizationDetails.ProviderId;
ProviderName = organizationDetails.ProviderName;
ProviderType = organizationDetails.ProviderType;
Permissions = new Permissions();
ResetPasswordEnrolled = false;
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;
AccessSecretsManager = false; // Provider users cannot access Secrets Manager
}
}

View File

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

View File

@@ -1,7 +1,4 @@
// FIXME: Update this file to be null safe and then delete the line below
#nullable disable
using System.Net;
using System.Net;
using Bit.Api.AdminConsole.Public.Models.Request;
using Bit.Api.AdminConsole.Public.Models.Response;
using Bit.Api.Models.Public.Response;
@@ -24,11 +21,9 @@ public class MembersController : Controller
private readonly IOrganizationUserRepository _organizationUserRepository;
private readonly IGroupRepository _groupRepository;
private readonly IOrganizationService _organizationService;
private readonly IUserService _userService;
private readonly ICurrentContext _currentContext;
private readonly IUpdateOrganizationUserCommand _updateOrganizationUserCommand;
private readonly IUpdateOrganizationUserGroupsCommand _updateOrganizationUserGroupsCommand;
private readonly IApplicationCacheService _applicationCacheService;
private readonly IPaymentService _paymentService;
private readonly IOrganizationRepository _organizationRepository;
private readonly ITwoFactorIsEnabledQuery _twoFactorIsEnabledQuery;
@@ -39,11 +34,9 @@ public class MembersController : Controller
IOrganizationUserRepository organizationUserRepository,
IGroupRepository groupRepository,
IOrganizationService organizationService,
IUserService userService,
ICurrentContext currentContext,
IUpdateOrganizationUserCommand updateOrganizationUserCommand,
IUpdateOrganizationUserGroupsCommand updateOrganizationUserGroupsCommand,
IApplicationCacheService applicationCacheService,
IPaymentService paymentService,
IOrganizationRepository organizationRepository,
ITwoFactorIsEnabledQuery twoFactorIsEnabledQuery,
@@ -53,11 +46,9 @@ public class MembersController : Controller
_organizationUserRepository = organizationUserRepository;
_groupRepository = groupRepository;
_organizationService = organizationService;
_userService = userService;
_currentContext = currentContext;
_updateOrganizationUserCommand = updateOrganizationUserCommand;
_updateOrganizationUserGroupsCommand = updateOrganizationUserGroupsCommand;
_applicationCacheService = applicationCacheService;
_paymentService = paymentService;
_organizationRepository = organizationRepository;
_twoFactorIsEnabledQuery = twoFactorIsEnabledQuery;
@@ -115,19 +106,18 @@ public class MembersController : Controller
/// </summary>
/// <remarks>
/// 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>
[HttpGet]
[ProducesResponseType(typeof(ListResponseModel<MemberResponseModel>), (int)HttpStatusCode.OK)]
public async Task<IActionResult> List()
{
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId.Value);
// TODO: Get all CollectionUser associations for the organization and marry them up here for the response.
var organizationUserUserDetails = await _organizationUserRepository.GetManyDetailsByOrganizationAsync(_currentContext.OrganizationId!.Value, includeCollections: true);
var orgUsersTwoFactorIsEnabled = await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(organizationUserUserDetails);
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);
return new JsonResult(response);
@@ -158,7 +148,7 @@ public class MembersController : Controller
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);
var response = new MemberResponseModel(user, invite.Collections);
return new JsonResult(response);
@@ -188,12 +178,12 @@ public class MembersController : Controller
var updatedUser = model.ToOrganizationUser(existingUser);
var associations = model.Collections?.Select(c => c.ToCollectionAccessSelection()).ToList();
await _updateOrganizationUserCommand.UpdateUserAsync(updatedUser, existingUserType, null, associations, model.Groups);
MemberResponseModel response = null;
MemberResponseModel response;
if (existingUser.UserId.HasValue)
{
var existingUserDetails = await _organizationUserRepository.GetDetailsByIdAsync(id);
response = new MemberResponseModel(existingUserDetails,
await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails), associations);
response = new MemberResponseModel(existingUserDetails!,
await _twoFactorIsEnabledQuery.TwoFactorIsEnabledAsync(existingUserDetails!), associations);
}
else
{
@@ -242,7 +232,7 @@ public class MembersController : Controller
{
return new NotFoundResult();
}
await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId.Value, id, null);
await _removeOrganizationUserCommand.RemoveUserAsync(_currentContext.OrganizationId!.Value, id, null);
return new OkResult();
}
@@ -264,7 +254,7 @@ public class MembersController : Controller
{
return new NotFoundResult();
}
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId.Value, null, id);
await _resendOrganizationInviteCommand.ResendInviteAsync(_currentContext.OrganizationId!.Value, null, id);
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.OrganizationFeatures.Policies.Models;
using Bit.Core.AdminConsole.Utilities;
using Bit.Core.Enums;
namespace Bit.Api.AdminConsole.Public.Models.Request;
public class PolicyUpdateRequestModel : PolicyBaseModel
{
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type) => new()
public PolicyUpdate ToPolicyUpdate(Guid organizationId, PolicyType type)
{
Type = type,
OrganizationId = organizationId,
Data = Data != null ? JsonSerializer.Serialize(Data) : null,
Enabled = Enabled.GetValueOrDefault(),
PerformedBy = new SystemUser(EventSystemUser.PublicApi)
};
var serializedData = PolicyDataValidator.ValidateAndSerialize(Data, type);
return new()
{
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;
public class AssociationWithPermissionsResponseModel : AssociationWithPermissionsBaseModel
{
[JsonConstructor]
public AssociationWithPermissionsResponseModel() : base()
{
}
public AssociationWithPermissionsResponseModel(CollectionAccessSelection selection)
{
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;
public class PaymentMethodTypeValidationAttribute : StringMatchesAttribute
public class TokenizedPaymentMethodTypeValidationAttribute : StringMatchesAttribute
{
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)}";
}

View File

@@ -89,19 +89,6 @@ public class OrganizationSponsorshipsController : Controller
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(
sponsoringOrg,
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.Mvc;
namespace Bit.Api.Controllers;
namespace Bit.Api.Billing.Controllers;
[Route("plans")]
[Authorize("Web")]
[Authorize("Application")]
public class PlansController(
IPricingClient pricingClient) : Controller
{
@@ -18,4 +18,11 @@ public class PlansController(
var responses = plans.Select(plan => new PlanResponseModel(plan));
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
{
[Required]
[PaymentMethodTypeValidation]
[TokenizedPaymentMethodTypeValidation]
public required string Type { get; set; }
[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;
public class PremiumCloudHostedSubscriptionRequest
public class PremiumCloudHostedSubscriptionRequest : IValidatableObject
{
[Required]
public required MinimalTokenizedPaymentMethodRequest TokenizedPaymentMethod { get; set; }
public MinimalTokenizedPaymentMethodRequest? TokenizedPaymentMethod { get; set; }
public NonTokenizedPaymentMethodRequest? NonTokenizedPaymentMethod { get; set; }
[Required]
public required MinimalBillingAddressRequest BillingAddress { get; set; }
@@ -15,11 +15,38 @@ public class PremiumCloudHostedSubscriptionRequest
[Range(0, 99)]
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();
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")]
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 _organizationRepository.GetByIdAsync(sponsoringOrgId),
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.Requests;
using Bit.Core.Exceptions;
@@ -61,8 +62,9 @@ public class OrganizationReportsController : Controller
}
var latestReport = await _getOrganizationReportQuery.GetLatestOrganizationReportAsync(organizationId);
var response = latestReport == null ? null : new OrganizationReportResponseModel(latestReport);
return Ok(latestReport);
return Ok(response);
}
[HttpGet("{organizationId}/{reportId}")]
@@ -102,7 +104,8 @@ public class OrganizationReportsController : Controller
}
var report = await _addOrganizationReportCommand.AddOrganizationReportAsync(request);
return Ok(report);
var response = report == null ? null : new OrganizationReportResponseModel(report);
return Ok(response);
}
[HttpPatch("{organizationId}/{reportId}")]
@@ -119,7 +122,8 @@ public class OrganizationReportsController : Controller
}
var updatedReport = await _updateOrganizationReportCommand.UpdateOrganizationReportAsync(request);
return Ok(updatedReport);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
}
#endregion
@@ -182,10 +186,10 @@ public class OrganizationReportsController : Controller
{
throw new BadRequestException("Report ID in the request body must match the route parameter");
}
var updatedReport = await _updateOrganizationReportSummaryCommand.UpdateOrganizationReportSummaryAsync(request);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(updatedReport);
return Ok(response);
}
#endregion
@@ -228,7 +232,9 @@ public class OrganizationReportsController : Controller
}
var updatedReport = await _updateOrganizationReportDataCommand.UpdateOrganizationReportDataAsync(request);
return Ok(updatedReport);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(response);
}
#endregion
@@ -265,7 +271,6 @@ public class OrganizationReportsController : Controller
{
try
{
if (!await _currentContext.AccessReports(organizationId))
{
throw new NotFoundException();
@@ -282,10 +287,9 @@ public class OrganizationReportsController : Controller
}
var updatedReport = await _updateOrganizationReportApplicationDataCommand.UpdateOrganizationReportApplicationDataAsync(request);
var response = new OrganizationReportResponseModel(updatedReport);
return Ok(updatedReport);
return Ok(response);
}
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.AddDistributedCache(globalSettings);
// BitPay
services.AddSingleton<BitPayClient>();
if (!globalSettings.SelfHosted)
{
services.AddIpRateLimiting(globalSettings);

View File

@@ -74,10 +74,14 @@ public class ImportCiphersController : Controller
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 collections = model.Collections.Select(c => c.ToCollection(orgId)).ToList();
//An User is allowed to import if CanCreate Collections or has AccessToImportExport
var authorized = await CheckOrgImportPermission(collections, orgId);
if (!authorized)
@@ -156,7 +160,7 @@ public class ImportCiphersController : Controller
if (existingCollections.Any() && (await _authorizationService.AuthorizeAsync(User, existingCollections, BulkCollectionOperations.ImportCiphers)).Succeeded)
{
return true;
};
}
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
#nullable disable
using System.Globalization;
using System.Text.Json;
using Azure.Messaging.EventGrid;
using Bit.Api.Auth.Models.Request.Accounts;
@@ -1366,7 +1367,7 @@ public class CiphersController : Controller
}
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
{
AttachmentId = attachmentId,
@@ -1419,9 +1420,11 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
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();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (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(
@@ -1469,10 +1474,13 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (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);
@@ -1515,10 +1523,13 @@ public class CiphersController : Controller
throw new NotFoundException();
}
// Extract lastKnownRevisionDate from form data if present
DateTime? lastKnownRevisionDate = GetLastKnownRevisionDateFromForm();
await Request.GetFileAsync(async (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);
}
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 long FileSize { get; set; }
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": {
"production": false,
"token": "SECRET",
"notificationUrl": "https://bitwarden.com/SECRET"
"notificationUrl": "https://bitwarden.com/SECRET",
"webhookKey": "SECRET"
},
"amazon": {
"accessKeyId": "SECRET",

View File

@@ -8,7 +8,6 @@ public class BillingSettings
public virtual string JobsKey { get; set; }
public virtual string StripeWebhookKey { get; set; }
public virtual string StripeWebhookSecret20250827Basil { get; set; }
public virtual string BitPayWebhookKey { get; set; }
public virtual string AppleWebhookKey { get; set; }
public virtual FreshDeskSettings FreshDesk { get; set; } = new FreshDeskSettings();
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
#nullable disable
using System.Globalization;
using Bit.Billing.Constants;
using System.Globalization;
using Bit.Billing.Models;
using Bit.Core.AdminConsole.Repositories;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Billing.Services;
using Bit.Core.Entities;
using Bit.Core.Enums;
using Bit.Core.Repositories;
using Bit.Core.Services;
using Bit.Core.Settings;
using Bit.Core.Utilities;
using BitPayLight.Models.Invoice;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
namespace Bit.Billing.Controllers;
using static BitPayConstants;
using static StripeConstants;
[Route("bitpay")]
[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")]
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();
}
if (model == null || string.IsNullOrWhiteSpace(model.Data?.Id) ||
string.IsNullOrWhiteSpace(model.Event?.Name))
{
return new BadRequestResult();
return new BadRequestObjectResult("Invalid key");
}
if (model.Event.Name != BitPayNotificationCode.InvoiceConfirmed)
{
// 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();
}
var invoice = await bitPayClient.GetInvoice(model.Data.Id);
if (invoice.Currency != "USD")
{
// Only process USD payments
_logger.LogWarning("Non USD payment received. #{InvoiceId}", invoice.Id);
return new OkResult();
logger.LogWarning("Received BitPay invoice webhook for invoice ({InvoiceID}) with non-USD currency: {Currency}", invoice.Id, invoice.Currency);
return new BadRequestObjectResult("Cannot process non-USD payments");
}
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 (!isAccountCredit)
if (invoice.Status != InvoiceStatuses.Complete)
{
// Only processing credits
_logger.LogWarning("Non-credit payment received. #{InvoiceId}", invoice.Id);
return new OkResult();
logger.LogInformation("Received valid BitPay invoice webhook for invoice ({InvoiceID}) that is not yet complete: {Status}",
invoice.Id, invoice.Status);
return new OkObjectResult("Waiting for invoice to be completed");
}
var transaction = await _transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
if (transaction != null)
var existingTransaction = await transactionRepository.GetByGatewayIdAsync(GatewayType.BitPay, invoice.Id);
if (existingTransaction != null)
{
_logger.LogWarning("Already processed this invoice. #{InvoiceId}", invoice.Id);
return new OkResult();
logger.LogWarning("Already processed BitPay invoice webhook for invoice ({InvoiceID})", invoice.Id);
return new OkObjectResult("Invoice already processed");
}
try
{
var tx = new Transaction
var transaction = new Transaction
{
Amount = Convert.ToDecimal(invoice.Price),
CreationDate = GetTransactionDate(invoice),
@@ -132,50 +86,47 @@ public class BitPayController : Controller
PaymentMethodType = PaymentMethodType.BitPay,
Details = $"{invoice.Currency}, BitPay {invoice.Id}"
};
await _transactionRepository.CreateAsync(tx);
string billingEmail = null;
if (tx.OrganizationId.HasValue)
await transactionRepository.CreateAsync(transaction);
var billingEmail = "";
if (transaction.OrganizationId.HasValue)
{
var org = await _organizationRepository.GetByIdAsync(tx.OrganizationId.Value);
if (org != null)
var organization = await organizationRepository.GetByIdAsync(transaction.OrganizationId.Value);
if (organization != null)
{
billingEmail = org.BillingEmailAddress();
if (await _paymentService.CreditAccountAsync(org, tx.Amount))
billingEmail = organization.BillingEmailAddress();
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)
{
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)
{
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))
{
await _mailService.SendAddedCreditAsync(billingEmail, tx.Amount);
await mailService.SendAddedCreditAsync(billingEmail, transaction.Amount);
}
}
// Catch foreign key violations because user/org could have been deleted.
@@ -186,58 +137,34 @@ public class BitPayController : Controller
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 &&
!string.IsNullOrWhiteSpace(t.Confirmations) && t.Confirmations != "0");
if (transactions != null && transactions.Count() == 1)
if (invoice.PosData is null or { Length: 0 } || !invoice.PosData.Contains(':'))
{
return DateTime.Parse(transactions.First().ReceivedTime, CultureInfo.InvariantCulture,
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);
return new ValueTuple<Guid?, Guid?, Guid?>(null, null, null);
}
var mainParts = invoice.PosData.Split(',');
foreach (var mainPart in mainParts)
{
var parts = mainPart.Split(':');
var ids = invoice.PosData
.Split(',')
.Select(part => part.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))
{
continue;
}
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);
return new ValueTuple<Guid?, Guid?, Guid?>(
ids.TryGetValue(MetadataKeys.OrganizationId, out var id) ? id : null,
ids.TryGetValue(MetadataKeys.UserId, out id) ? id : null,
ids.TryGetValue(MetadataKeys.ProviderId, out id) ? id : null
);
}
}

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.Jobs;
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.Services;
using Quartz;
using Event = Stripe.Event;
namespace Bit.Billing.Services.Implementations;
@@ -11,17 +15,26 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
private readonly IUserService _userService;
private readonly IStripeEventUtilityService _stripeEventUtilityService;
private readonly IOrganizationDisableCommand _organizationDisableCommand;
private readonly IProviderRepository _providerRepository;
private readonly IProviderService _providerService;
private readonly ISchedulerFactory _schedulerFactory;
public SubscriptionDeletedHandler(
IStripeEventService stripeEventService,
IUserService userService,
IStripeEventUtilityService stripeEventUtilityService,
IOrganizationDisableCommand organizationDisableCommand)
IOrganizationDisableCommand organizationDisableCommand,
IProviderRepository providerRepository,
IProviderService providerService,
ISchedulerFactory schedulerFactory)
{
_stripeEventService = stripeEventService;
_userService = userService;
_stripeEventUtilityService = stripeEventUtilityService;
_organizationDisableCommand = organizationDisableCommand;
_providerRepository = providerRepository;
_providerService = providerService;
_schedulerFactory = schedulerFactory;
}
/// <summary>
@@ -53,9 +66,38 @@ public class SubscriptionDeletedHandler : ISubscriptionDeletedHandler
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)
{
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
services.AddDatabaseRepositories(globalSettings);
// BitPay Client
services.AddSingleton<BitPayClient>();
// PayPal IPN Client
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();

View File

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

View File

@@ -45,7 +45,7 @@ public static class PolicyTypeExtensions
PolicyType.MaximumVaultTimeout => "Vault timeout",
PolicyType.DisablePersonalVaultExport => "Remove individual vault export",
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.RemoveUnlockWithPin => "Remove unlock with PIN",
PolicyType.RestrictedItemTypesPolicy => "Restricted item types",

View File

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

View File

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

View File

@@ -23,7 +23,17 @@ public class IntegrationTemplateContext(EventMessage eventMessage)
public Guid? CollectionId => Event.CollectionId;
public Guid? GroupId => Event.GroupId;
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 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
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.AdminConsole.Models.Data;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.Models.Data.Organizations.OrganizationUsers;
public class OrganizationUserOrganizationDetails
public class OrganizationUserOrganizationDetails : IProfileOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
public Guid OrganizationUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Name { get; set; } = null!;
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
@@ -33,24 +31,24 @@ public class OrganizationUserOrganizationDetails
public int? Seats { get; set; }
public short? MaxCollections { 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.OrganizationUserType Type { get; set; }
public bool Enabled { get; set; }
public PlanType PlanType { get; set; }
public string SsoExternalId { get; set; }
public string Identifier { get; set; }
public string Permissions { get; set; }
public string ResetPasswordKey { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public string? SsoExternalId { get; set; }
public string? Identifier { get; set; }
public string? Permissions { get; set; }
public string? ResetPasswordKey { get; set; }
public string? PublicKey { get; set; }
public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public string? ProviderName { get; set; }
public ProviderType? ProviderType { get; set; }
public string FamilySponsorshipFriendlyName { get; set; }
public string? FamilySponsorshipFriendlyName { get; set; }
public bool? SsoEnabled { get; set; }
public string SsoConfig { get; set; }
public string? SsoConfig { get; set; }
public DateTime? FamilySponsorshipLastSyncDate { get; set; }
public DateTime? FamilySponsorshipValidUntil { 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
#nullable disable
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Bit.Core.AdminConsole.Enums.Provider;
using Bit.Core.Billing.Enums;
using Bit.Core.Utilities;
namespace Bit.Core.AdminConsole.Models.Data.Provider;
public class ProviderUserOrganizationDetails
public class ProviderUserOrganizationDetails : IProfileOrganizationDetails
{
public Guid OrganizationId { get; set; }
public Guid? UserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string Name { get; set; }
public string Name { get; set; } = null!;
public bool UsePolicies { get; set; }
public bool UseSso { get; set; }
public bool UseKeyConnector { get; set; }
@@ -28,20 +25,22 @@ public class ProviderUserOrganizationDetails
public bool SelfHost { get; set; }
public bool UsersGetPremium { get; set; }
public bool UseCustomPermissions { get; set; }
public bool UseSecretsManager { get; set; }
public bool UsePasswordManager { get; set; }
public int? Seats { get; set; }
public short? MaxCollections { get; set; }
public short? MaxStorageGb { get; set; }
public string Key { get; set; }
public string? Key { get; set; }
public ProviderUserStatusType Status { get; set; }
public ProviderUserType Type { get; set; }
public bool Enabled { get; set; }
public string Identifier { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
public string? Identifier { get; set; }
public string? PublicKey { get; set; }
public string? PrivateKey { get; set; }
public Guid? ProviderId { get; set; }
public Guid? ProviderUserId { get; set; }
[JsonConverter(typeof(HtmlEncodingStringConverter))]
public string ProviderName { get; set; }
public string? ProviderName { get; set; }
public PlanType PlanType { get; set; }
public bool LimitCollectionCreation { get; set; }
public bool LimitCollectionDeletion { get; set; }
@@ -50,6 +49,11 @@ public class ProviderUserOrganizationDetails
public bool UseRiskInsights { get; set; }
public bool UseOrganizationDomains { get; set; }
public bool UseAdminSponsoredFamilies { get; set; }
public ProviderType ProviderType { get; set; }
public ProviderType? ProviderType { 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.OrganizationFeatures.Policies.PolicyRequirements;
using Bit.Core.AdminConsole.Repositories;
@@ -20,7 +18,7 @@ public class PolicyRequirementQuery(
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
.Where(p => p.PolicyType == factory.PolicyType)
.Where(factory.Enforce);
@@ -48,8 +46,8 @@ public class PolicyRequirementQuery(
return eligibleOrganizationUserIds;
}
private Task<IEnumerable<PolicyDetails>> GetPolicyDetails(Guid userId)
=> policyRepository.GetPolicyDetailsByUserId(userId);
private async Task<IEnumerable<OrganizationPolicyDetails>> GetPolicyDetails(Guid userId, PolicyType policyType)
=> await policyRepository.GetPolicyDetailsByUserIdsAndPolicyType([userId], policyType);
private async Task<IEnumerable<OrganizationPolicyDetails>> GetOrganizationPolicyDetails(Guid organizationId, PolicyType policyType)
=> await policyRepository.GetPolicyDetailsByOrganizationIdAsync(organizationId, policyType);

View File

@@ -33,6 +33,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyValidator, RequireSsoPolicyValidator>();
services.AddScoped<IPolicyValidator, ResetPasswordPolicyValidator>();
services.AddScoped<IPolicyValidator, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyValidator, UriMatchDefaultPolicyValidator>();
services.AddScoped<IPolicyValidator, FreeFamiliesForEnterprisePolicyValidator>();
}
@@ -51,6 +52,7 @@ public static class PolicyServiceCollectionExtensions
services.AddScoped<IPolicyUpdateEvent, MaximumVaultTimeoutPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, FreeFamiliesForEnterprisePolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, OrganizationDataOwnershipPolicyValidator>();
services.AddScoped<IPolicyUpdateEvent, UriMatchDefaultPolicyValidator>();
}
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<ICollection<Policy>> GetManyByOrganizationIdAsync(Guid organizationId);
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>
/// Retrieves <see cref="OrganizationPolicyDetails"/> of the specified <paramref name="policyType"/>

View File

@@ -5,5 +5,5 @@ namespace Bit.Core.Services;
public interface IEventIntegrationPublisher : IAsyncDisposable
{
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)
{
Subject = message.IntegrationType.ToRoutingKey(),
MessageId = message.MessageId
MessageId = message.MessageId,
PartitionKey = message.OrganizationId
};
await _integrationSender.SendMessageAsync(serviceBusMessage);
@@ -44,18 +45,20 @@ public class AzureServiceBusService : IAzureServiceBusService
{
Subject = message.IntegrationType.ToRoutingKey(),
ScheduledEnqueueTime = message.DelayUntilDate ?? DateTime.UtcNow,
MessageId = message.MessageId
MessageId = message.MessageId,
PartitionKey = message.OrganizationId
};
await _integrationSender.SendMessageAsync(serviceBusMessage);
}
public async Task PublishEventAsync(string body)
public async Task PublishEventAsync(string body, string? organizationId)
{
var message = new ServiceBusMessage(body)
{
ContentType = "application/json",
MessageId = Guid.NewGuid().ToString()
MessageId = Guid.NewGuid().ToString(),
PartitionKey = organizationId
};
await _eventSender.SendMessageAsync(message);

View File

@@ -14,15 +14,21 @@ public class EventIntegrationEventWriteService : IEventWriteService, IAsyncDispo
public async Task CreateAsync(IEvent 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)
{
var body = JsonSerializer.Serialize(events);
await _eventIntegrationPublisher.PublishEventAsync(body: body);
}
var eventList = events as IList<IEvent> ?? events.ToList();
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()
{
await _eventIntegrationPublisher.DisposeAsync();

View File

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

View File

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

View File

@@ -111,5 +111,6 @@ public static class OrganizationFactory
UseRiskInsights = license.UseRiskInsights,
UseOrganizationDomains = license.UseOrganizationDomains,
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 valid = string.Equals(token, code);
var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid)
{
await _distributedCache.RemoveAsync(cacheKey);

View File

@@ -64,7 +64,7 @@ public class OtpTokenProvider<TOptions>(
}
var code = Encoding.UTF8.GetString(cachedValue);
var valid = string.Equals(token, code);
var valid = CoreHelpers.FixedTimeEquals(token, code);
if (valid)
{
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 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.Services;
using Bit.Core.Exceptions;
@@ -52,6 +54,12 @@ public class UpdateOrganizationLicenseCommand : IUpdateOrganizationLicenseComman
throw new BadRequestException(exception);
}
var useAutomaticUserConfirmation = claimsPrincipal?
.GetValue<bool>(OrganizationLicenseConstants.UseAutomaticUserConfirmation) ?? false;
selfHostedOrganization.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
license.UseAutomaticUserConfirmation = useAutomaticUserConfirmation;
await WriteLicenseFileAsync(selfHostedOrganization, license);
await UpdateOrganizationAsync(selfHostedOrganization, license);
}

View File

@@ -1,6 +1,7 @@
using Bit.Core.AdminConsole.Entities;
using Bit.Core.AdminConsole.Entities.Provider;
using Bit.Core.Billing.Commands;
using Bit.Core.Billing.Constants;
using Bit.Core.Billing.Payment.Clients;
using Bit.Core.Entities;
using Bit.Core.Settings;
@@ -9,6 +10,8 @@ using Microsoft.Extensions.Logging;
namespace Bit.Core.Billing.Payment.Commands;
using static BitPayConstants;
public interface ICreateBitPayInvoiceForCreditCommand
{
Task<BillingCommandResult<string>> Run(
@@ -31,6 +34,8 @@ public class CreateBitPayInvoiceForCreditCommand(
{
var (name, email, posData) = GetSubscriberInformation(subscriber);
var notificationUrl = $"{globalSettings.BitPay.NotificationUrl}?key={globalSettings.BitPay.WebhookKey}";
var invoice = new Invoice
{
Buyer = new Buyer { Email = email, Name = name },
@@ -38,7 +43,7 @@ public class CreateBitPayInvoiceForCreditCommand(
ExtendedNotifications = true,
FullNotifications = true,
ItemDesc = "Bitwarden",
NotificationUrl = globalSettings.BitPay.NotificationUrl,
NotificationUrl = notificationUrl,
PosData = posData,
Price = Convert.ToDouble(amount),
RedirectUrl = redirectUrl
@@ -51,10 +56,10 @@ public class CreateBitPayInvoiceForCreditCommand(
private static (string? Name, string? Email, string POSData) GetSubscriberInformation(
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,
$"organizationId:{organization.Id},accountCredit:1"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},accountCredit:1"),
$"organizationId:{organization.Id},{PosDataKeys.AccountCredit}"),
Provider provider => (provider.Name, provider.BillingEmail, $"providerId:{provider.Id},{PosDataKeys.AccountCredit}"),
_ => 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.Constants;
using Bit.Core.Billing.Extensions;
using Bit.Core.Billing.Payment.Commands;
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.Entities;
using Bit.Core.Enums;
@@ -15,10 +18,12 @@ using Microsoft.Extensions.Logging;
using OneOf.Types;
using Stripe;
using Customer = Stripe.Customer;
using PaymentMethod = Bit.Core.Billing.Payment.Models.PaymentMethod;
using Subscription = Stripe.Subscription;
namespace Bit.Core.Billing.Premium.Commands;
using static StripeConstants;
using static Utilities;
/// <summary>
@@ -30,14 +35,14 @@ public interface ICreatePremiumCloudHostedSubscriptionCommand
/// <summary>
/// Creates a premium cloud-hosted subscription for the specified user.
/// </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="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>
/// <returns>A billing command result indicating success or failure with appropriate error details.</returns>
Task<BillingCommandResult<None>> Run(
User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb);
}
@@ -50,7 +55,10 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
ISubscriberService subscriberService,
IUserService userService,
IPushNotificationService pushNotificationService,
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger)
ILogger<CreatePremiumCloudHostedSubscriptionCommand> logger,
IPricingClient pricingClient,
IHasPaymentMethodQuery hasPaymentMethodQuery,
IUpdatePaymentMethodCommand updatePaymentMethodCommand)
: BaseBillingCommand<CreatePremiumCloudHostedSubscriptionCommand>(logger), ICreatePremiumCloudHostedSubscriptionCommand
{
private static readonly List<string> _expand = ["tax"];
@@ -58,7 +66,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
public Task<BillingCommandResult<None>> Run(
User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
BillingAddress billingAddress,
short additionalStorageGb) => HandleAsync<None>(async () =>
{
@@ -72,26 +80,62 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
return new BadRequest("Additional storage must be greater than 0.");
}
var customer = string.IsNullOrEmpty(user.GatewayCustomerId)
? await CreateCustomerAsync(user, paymentMethod, billingAddress)
: await subscriberService.GetCustomerOrThrow(user, new CustomerGetOptions { Expand = _expand });
Customer? customer;
/*
* 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);
var subscription = await CreateSubscriptionAsync(user.Id, customer, additionalStorageGb > 0 ? additionalStorageGb : null);
switch (paymentMethod)
{
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Incomplete:
case { Type: not TokenizablePaymentMethodType.PayPal }
when subscription.Status == StripeConstants.SubscriptionStatus.Active:
paymentMethod.Switch(
tokenized =>
{
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (tokenized)
{
user.Premium = true;
user.PremiumExpirationDate = subscription.GetCurrentPeriodEnd();
break;
case { Type: TokenizablePaymentMethodType.PayPal }
when subscription.Status == SubscriptionStatus.Incomplete:
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.GatewayCustomerId = customer.Id;
@@ -107,9 +151,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
});
private async Task<Customer> CreateCustomerAsync(User user,
TokenizedPaymentMethod paymentMethod,
PaymentMethod paymentMethod,
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 customerCreateOptions = new CustomerCreateOptions
{
@@ -140,24 +190,25 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
},
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
[StripeConstants.MetadataKeys.UserId] = user.Id.ToString()
[MetadataKeys.Region] = globalSettings.BaseServiceUri.CloudRegion,
[MetadataKeys.UserId] = user.Id.ToString()
},
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
var braintreeCustomerId = "";
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
switch (paymentMethod.Type)
// We have checked that the payment method is tokenized, so we can safely cast it.
var tokenizedPaymentMethod = paymentMethod.AsTokenized;
switch (tokenizedPaymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
var setupIntent =
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = paymentMethod.Token }))
(await stripeAdapter.SetupIntentList(new SetupIntentListOptions { PaymentMethod = tokenizedPaymentMethod.Token }))
.FirstOrDefault();
if (setupIntent == null)
@@ -171,19 +222,19 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
}
case TokenizablePaymentMethodType.Card:
{
customerCreateOptions.PaymentMethod = paymentMethod.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = paymentMethod.Token;
customerCreateOptions.PaymentMethod = tokenizedPaymentMethod.Token;
customerCreateOptions.InvoiceSettings.DefaultPaymentMethod = tokenizedPaymentMethod.Token;
break;
}
case TokenizablePaymentMethodType.PayPal:
{
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, paymentMethod.Token);
braintreeCustomerId = await subscriberService.CreateBraintreeCustomer(user, tokenizedPaymentMethod.Token);
customerCreateOptions.Metadata[BraintreeCustomerIdKey] = braintreeCustomerId;
break;
}
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();
}
}
@@ -201,7 +252,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
async Task Revert()
{
// ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault
switch (paymentMethod.Type)
switch (tokenizedPaymentMethod.Type)
{
case TokenizablePaymentMethodType.BankAccount:
{
@@ -244,7 +295,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Expand = _expand,
Tax = new CustomerTaxOptions
{
ValidateLocation = StripeConstants.ValidateTaxLocationTiming.Immediately
ValidateLocation = ValidateTaxLocationTiming.Immediately
}
};
return await stripeAdapter.CustomerUpdateAsync(customer.Id, options);
@@ -255,11 +306,13 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
Customer customer,
int? storage)
{
var premiumPlan = await pricingClient.GetAvailablePremiumPlan();
var subscriptionItemOptionsList = new List<SubscriptionItemOptions>
{
new ()
{
Price = StripeConstants.Prices.PremiumAnnually,
Price = premiumPlan.Seat.StripePriceId,
Quantity = 1
}
};
@@ -268,7 +321,7 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{
subscriptionItemOptionsList.Add(new SubscriptionItemOptions
{
Price = StripeConstants.Prices.StoragePlanPersonal,
Price = premiumPlan.Storage.StripePriceId,
Quantity = storage
});
}
@@ -281,15 +334,15 @@ public class CreatePremiumCloudHostedSubscriptionCommand(
{
Enabled = true
},
CollectionMethod = StripeConstants.CollectionMethod.ChargeAutomatically,
CollectionMethod = CollectionMethod.ChargeAutomatically,
Customer = customer.Id,
Items = subscriptionItemOptionsList,
Metadata = new Dictionary<string, string>
{
[StripeConstants.MetadataKeys.UserId] = userId.ToString()
[MetadataKeys.UserId] = userId.ToString()
},
PaymentBehavior = usingPayPal
? StripeConstants.PaymentBehavior.DefaultIncomplete
? PaymentBehavior.DefaultIncomplete
: null,
OffSession = true
};

View File

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

View File

@@ -3,12 +3,14 @@ using Bit.Core.Exceptions;
using Bit.Core.Models.StaticStore;
using Bit.Core.Utilities;
#nullable enable
namespace Bit.Core.Billing.Pricing;
using OrganizationPlan = Plan;
using PremiumPlan = Premium.Plan;
public interface IPricingClient
{
// TODO: Rename with Organization focus.
/// <summary>
/// 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"/>.
@@ -16,8 +18,9 @@ public interface IPricingClient
/// <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>
/// <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>
/// 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"/>.
@@ -26,13 +29,17 @@ public interface IPricingClient
/// <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="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>
/// 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"/>.
/// </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>
/// <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
{

View File

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

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