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:
25
.claude/prompts/review-code.md
Normal file
25
.claude/prompts/review-code.md
Normal file
@@ -0,0 +1,25 @@
|
||||
Please review this pull request with a focus on:
|
||||
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Security implications
|
||||
- Performance considerations
|
||||
|
||||
Note: The PR branch is already checked out in the current working directory.
|
||||
|
||||
Provide a comprehensive review including:
|
||||
|
||||
- Summary of changes since last review
|
||||
- Critical issues found (be thorough)
|
||||
- Suggested improvements (be thorough)
|
||||
- Good practices observed (be concise - list only the most notable items without elaboration)
|
||||
- Action items for the author
|
||||
- Leverage collapsible <details> sections where appropriate for lengthy explanations or code snippets to enhance human readability
|
||||
|
||||
When reviewing subsequent commits:
|
||||
|
||||
- Track status of previously identified issues (fixed/unfixed/reopened)
|
||||
- Identify NEW problems introduced since last review
|
||||
- Note if fixes introduced new issues
|
||||
|
||||
IMPORTANT: Be comprehensive about issues and improvements. For good practices, be brief - just note what was done well without explaining why or praising excessively.
|
||||
5
.github/CODEOWNERS
vendored
5
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
28
.github/workflows/_move_edd_db_scripts.yml
vendored
28
.github/workflows/_move_edd_db_scripts.yml
vendored
@@ -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' }}
|
||||
|
||||
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
12
.github/workflows/cleanup-after-pr.yml
vendored
12
.github/workflows/cleanup-after-pr.yml
vendored
@@ -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
|
||||
|
||||
14
.github/workflows/cleanup-rc-branch.yml
vendored
14
.github/workflows/cleanup-rc-branch.yml
vendored
@@ -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
|
||||
|
||||
10
.github/workflows/code-references.yml
vendored
10
.github/workflows/code-references.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/enforce-labels.yml
vendored
2
.github/workflows/enforce-labels.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ephemeral-environment.yml
vendored
2
.github/workflows/ephemeral-environment.yml
vendored
@@ -16,5 +16,5 @@ jobs:
|
||||
with:
|
||||
project: server
|
||||
pull_request_number: ${{ github.event.number }}
|
||||
sync_environment: true
|
||||
sync_environment: false
|
||||
secrets: inherit
|
||||
|
||||
4
.github/workflows/load-test.yml
vendored
4
.github/workflows/load-test.yml
vendored
@@ -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 \
|
||||
|
||||
5
.github/workflows/protect-files.yml
vendored
5
.github/workflows/protect-files.yml
vendored
@@ -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
|
||||
|
||||
|
||||
37
.github/workflows/publish.yml
vendored
37
.github/workflows/publish.yml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/release.yml
vendored
7
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
37
.github/workflows/repository-management.yml
vendored
37
.github/workflows/repository-management.yml
vendored
@@ -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
28
.github/workflows/respond.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: Respond
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
respond:
|
||||
name: Respond
|
||||
uses: bitwarden/gh-actions/.github/workflows/_respond.yml@main
|
||||
secrets:
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
id-token: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
118
.github/workflows/review-code.yml
vendored
118
.github/workflows/review-code.yml
vendored
@@ -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:*)"
|
||||
|
||||
14
.github/workflows/test-database.yml
vendored
14
.github/workflows/test-database.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -234,4 +234,6 @@ bitwarden_license/src/Sso/Sso.zip
|
||||
/identity.json
|
||||
/api.json
|
||||
/api.public.json
|
||||
|
||||
# Serena
|
||||
.serena/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
84
bitwarden_license/src/Sso/package-lock.json
generated
84
bitwarden_license/src/Sso/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
1029
bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs
Normal file
1029
bitwarden_license/test/SSO.Test/Controllers/AccountControllerTest.cs
Normal file
File diff suppressed because it is too large
Load Diff
35
bitwarden_license/test/SSO.Test/SSO.Test.csproj
Normal file
35
bitwarden_license/test/SSO.Test/SSO.Test.csproj
Normal 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>
|
||||
@@ -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">
|
||||
|
||||
84
src/Admin/package-lock.json
generated
84
src/Admin/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -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)}";
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ namespace Bit.Api.Billing.Models.Requests.Payment;
|
||||
public class MinimalTokenizedPaymentMethodRequest
|
||||
{
|
||||
[Required]
|
||||
[PaymentMethodTypeValidation]
|
||||
[TokenizedPaymentMethodTypeValidation]
|
||||
public required string Type { get; set; }
|
||||
|
||||
[Required]
|
||||
|
||||
@@ -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)}")
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,9 +94,6 @@ public class Startup
|
||||
services.AddMemoryCache();
|
||||
services.AddDistributedCache(globalSettings);
|
||||
|
||||
// BitPay
|
||||
services.AddSingleton<BitPayClient>();
|
||||
|
||||
if (!globalSettings.SelfHosted)
|
||||
{
|
||||
services.AddIpRateLimiting(globalSettings);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
87
src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs
Normal file
87
src/Api/Utilities/DiagnosticTools/EventDiagnosticLogger.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
"bitPay": {
|
||||
"production": false,
|
||||
"token": "SECRET",
|
||||
"notificationUrl": "https://bitwarden.com/SECRET"
|
||||
"notificationUrl": "https://bitwarden.com/SECRET",
|
||||
"webhookKey": "SECRET"
|
||||
},
|
||||
"amazon": {
|
||||
"accessKeyId": "SECRET",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace Bit.Billing.Constants;
|
||||
|
||||
public static class BitPayInvoiceStatus
|
||||
{
|
||||
public const string Confirmed = "confirmed";
|
||||
public const string Complete = "complete";
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
88
src/Billing/Jobs/ProviderOrganizationDisableJob.cs
Normal file
88
src/Billing/Jobs/ProviderOrganizationDisableJob.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,9 +51,6 @@ public class Startup
|
||||
// Repositories
|
||||
services.AddDatabaseRepositories(globalSettings);
|
||||
|
||||
// BitPay Client
|
||||
services.AddSingleton<BitPayClient>();
|
||||
|
||||
// PayPal IPN Client
|
||||
services.AddHttpClient<IPayPalIPNClient, PayPalIPNClient>();
|
||||
|
||||
|
||||
@@ -333,5 +333,6 @@ public class Organization : ITableObject<Guid>, IStorableSubscriber, IRevisable
|
||||
UseRiskInsights = license.UseRiskInsights;
|
||||
UseOrganizationDomains = license.UseOrganizationDomains;
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies;
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -57,6 +57,7 @@ public class EventIntegrationHandler<T>(
|
||||
{
|
||||
IntegrationType = integrationType,
|
||||
MessageId = messageId.ToString(),
|
||||
OrganizationId = organizationId.ToString(),
|
||||
Configuration = config,
|
||||
RenderedTemplate = renderedTemplate,
|
||||
RetryCount = 0,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -111,5 +111,6 @@ public static class OrganizationFactory
|
||||
UseRiskInsights = license.UseRiskInsights,
|
||||
UseOrganizationDomains = license.UseOrganizationDomains,
|
||||
UseAdminSponsoredFamilies = license.UseAdminSponsoredFamilies,
|
||||
UseAutomaticUserConfirmation = license.UseAutomaticUserConfirmation
|
||||
};
|
||||
}
|
||||
|
||||
81
src/Core/AdminConsole/Utilities/PolicyDataValidator.cs
Normal file
81
src/Core/AdminConsole/Utilities/PolicyDataValidator.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
14
src/Core/Billing/Constants/BitPayConstants.cs
Normal file
14
src/Core/Billing/Constants/BitPayConstants.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
}
|
||||
|
||||
11
src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs
Normal file
11
src/Core/Billing/Payment/Models/NonTokenizedPaymentMethod.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace Bit.Core.Billing.Payment.Models;
|
||||
|
||||
public record NonTokenizedPaymentMethod
|
||||
{
|
||||
public NonTokenizablePaymentMethodType Type { get; set; }
|
||||
}
|
||||
|
||||
public enum NonTokenizablePaymentMethodType
|
||||
{
|
||||
AccountCredit,
|
||||
}
|
||||
71
src/Core/Billing/Payment/Models/PaymentMethod.cs
Normal file
71
src/Core/Billing/Payment/Models/PaymentMethod.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Bit.Core.Billing.Pricing.Models;
|
||||
namespace Bit.Core.Billing.Pricing.Organizations;
|
||||
|
||||
public class Feature
|
||||
{
|
||||
@@ -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
Reference in New Issue
Block a user